feat: support vc,note,minute event (#1113)

This commit is contained in:
hugang-lark
2026-05-26 22:17:54 +08:00
committed by GitHub
parent e182b01f68
commit 367cfc9d06
13 changed files with 1042 additions and 2 deletions

View File

@@ -0,0 +1,116 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package minutes
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/validate"
)
const (
minutesDetailRetryDelay = 500 * time.Millisecond
minutesDetailMaxRetries = 2
)
// MinutesMinuteSourceOutput is the flattened minute source payload.
type MinutesMinuteSourceOutput struct {
SourceType string `json:"source_type,omitempty" desc:"Minute source type"`
SourceEntityID string `json:"source_entity_id,omitempty" desc:"Source entity ID"`
}
// MinutesMinuteGeneratedOutput is the flattened shape for minutes.minute.generated_v1.
type MinutesMinuteGeneratedOutput struct {
Type string `json:"type" desc:"Event type; always minutes.minute.generated_v1"`
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"`
MinuteToken string `json:"minute_token,omitempty" desc:"Minute token"`
Title string `json:"title,omitempty" desc:"Minute title"`
MinuteSource *MinutesMinuteSourceOutput `json:"minute_source,omitempty" desc:"Minute source metadata"`
}
func processMinutesMinuteGenerated(ctx context.Context, rt event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
var envelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
} `json:"header"`
Event struct {
MinuteToken string `json:"minute_token"`
MinuteSource struct {
SourceType string `json:"source_type"`
SourceEntityID string `json:"source_entity_id"`
} `json:"minute_source"`
} `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 := &MinutesMinuteGeneratedOutput{
Type: envelope.Header.EventType,
EventID: envelope.Header.EventID,
Timestamp: envelope.Header.CreateTime,
MinuteToken: envelope.Event.MinuteToken,
}
if out.Type == "" {
out.Type = raw.EventType
}
if src := envelope.Event.MinuteSource; src.SourceType != "" || src.SourceEntityID != "" {
out.MinuteSource = &MinutesMinuteSourceOutput{
SourceType: src.SourceType,
SourceEntityID: src.SourceEntityID,
}
}
if rt != nil && out.MinuteToken != "" {
fillMinutesMinuteGeneratedDetails(ctx, rt, out)
}
return json.Marshal(out)
}
func fillMinutesMinuteGeneratedDetails(ctx context.Context, rt event.APIClient, out *MinutesMinuteGeneratedOutput) {
if rt == nil || out == nil || out.MinuteToken == "" {
return
}
path := fmt.Sprintf(pathMinuteDetailFmt, validate.EncodePathSegment(out.MinuteToken))
type minuteDetailResp struct {
Data struct {
Minute struct {
Title string `json:"title"`
} `json:"minute"`
} `json:"data"`
}
for attempt := 0; attempt <= minutesDetailMaxRetries; attempt++ {
if attempt > 0 {
time.Sleep(minutesDetailRetryDelay)
}
raw, err := rt.CallAPI(ctx, "GET", path, nil)
if err != nil {
continue
}
var resp minuteDetailResp
if err := json.Unmarshal(raw, &resp); err != nil {
continue
}
if resp.Data.Minute.Title == "" {
continue
}
out.Title = resp.Data.Minute.Title
return
}
}

View File

@@ -0,0 +1,353 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package minutes
import (
"context"
"encoding/json"
"fmt"
"os"
"reflect"
"testing"
"time"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/validate"
)
type stubAPIClient struct {
callFn func(ctx context.Context, method, path string, body any) (json.RawMessage, error)
}
func (s *stubAPIClient) CallAPI(ctx context.Context, method, path string, body any) (json.RawMessage, error) {
if s.callFn == nil {
return nil, nil
}
return s.callFn(ctx, method, path, body)
}
func assertSubscriptionRequest(t *testing.T, gotBody any, wantEventType string) {
t.Helper()
want := map[string]string{"event_type": wantEventType}
if !reflect.DeepEqual(gotBody, want) {
t.Fatalf("request body = %#v, want %#v", gotBody, want)
}
}
func TestMain(m *testing.M) {
for _, k := range Keys() {
event.RegisterKey(k)
}
os.Exit(m.Run())
}
func TestMinutesKeys_ProcessedMinuteGeneratedRegistered(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
def, ok := event.Lookup(eventTypeMinuteGenerated)
if !ok {
t.Fatalf("%s should be registered via Keys()", eventTypeMinuteGenerated)
}
if def.Schema.Custom == nil {
t.Error("Processed key must set Schema.Custom")
}
if def.Schema.Native != nil {
t.Error("Processed key must not set Schema.Native")
}
if def.Process == nil {
t.Error("Process must not be nil for processed key")
}
if def.PreConsume == nil {
t.Error("PreConsume must not be nil for processed key")
}
if len(def.Scopes) != 1 || def.Scopes[0] != "minutes:minutes.basic:read" {
t.Errorf("Scopes = %v", def.Scopes)
}
if len(def.AuthTypes) != 1 || def.AuthTypes[0] != "user" {
t.Errorf("AuthTypes = %v", def.AuthTypes)
}
}
func TestProcessMinutesMinuteGenerated(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
var gotMethod, gotPath string
rt := &stubAPIClient{
callFn: func(_ context.Context, method, path string, body any) (json.RawMessage, error) {
gotMethod = method
gotPath = path
if body != nil {
t.Fatalf("GET detail body = %#v, want nil", body)
}
return json.RawMessage(`{
"code": 0,
"msg": "success",
"data": {
"minute": {
"token": "<doc_token_001>",
"title": "产品周会的视频会议",
"note_id": "7616590025794260496"
}
}
}`), nil
},
}
out := runMinuteGenerated(t, rt, `{
"schema": "2.0",
"header": {
"event_id": "ev_minute_001",
"event_type": "minutes.minute.generated_v1",
"create_time": "1608725989000"
},
"event": {
"minute_token": "<doc_token_001>",
"minute_source": {
"source_type": "meeting",
"source_entity_id": "6911188411934433028"
}
}
}`)
if gotMethod != "GET" {
t.Errorf("detail method = %q, want GET", gotMethod)
}
if gotPath != fmt.Sprintf("/open-apis/minutes/v1/minutes/%s", validate.EncodePathSegment("<doc_token_001>")) {
t.Errorf("detail path = %q", gotPath)
}
if out.Type != eventTypeMinuteGenerated {
t.Errorf("Type = %q", out.Type)
}
if out.EventID != "ev_minute_001" || out.Timestamp != "1608725989000" {
t.Errorf("EventID/Timestamp = %q/%q", out.EventID, out.Timestamp)
}
if out.MinuteToken != "<doc_token_001>" {
t.Errorf("MinuteToken = %q", out.MinuteToken)
}
if out.Title != "产品周会的视频会议" {
t.Errorf("Title = %q", out.Title)
}
if out.MinuteSource == nil {
t.Fatal("MinuteSource should not be nil")
}
if out.MinuteSource.SourceType != "meeting" || out.MinuteSource.SourceEntityID != "6911188411934433028" {
t.Errorf("MinuteSource = %+v", out.MinuteSource)
}
}
func TestProcessMinutesMinuteGenerated_DetailFailureFallsBackToBaseFields(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
called := 0
rt := &stubAPIClient{
callFn: func(_ context.Context, method, path string, body any) (json.RawMessage, error) {
called++
return nil, context.DeadlineExceeded
},
}
out := runMinuteGenerated(t, rt, `{
"schema": "2.0",
"header": {
"event_id": "ev_minute_002",
"event_type": "minutes.minute.generated_v1",
"create_time": "1608725989001"
},
"event": {
"minute_token": "<doc_token_004>",
"minute_source": {
"source_type": "meeting",
"source_entity_id": "7641156270787481117"
}
}
}`)
wantCalls := 1 + minutesDetailMaxRetries
if called != wantCalls {
t.Fatalf("detail API called %d times, want %d", called, wantCalls)
}
if out.MinuteToken != "<doc_token_004>" {
t.Errorf("MinuteToken = %q", out.MinuteToken)
}
if out.Title != "" {
t.Errorf("Title = %q, want empty", out.Title)
}
if out.MinuteSource == nil {
t.Fatal("MinuteSource should remain from event payload")
}
if out.MinuteSource.SourceType != "meeting" || out.MinuteSource.SourceEntityID != "7641156270787481117" {
t.Errorf("MinuteSource = %+v", out.MinuteSource)
}
}
func TestProcessMinutesMinuteGenerated_EmptyTitleRetriesAndSucceeds(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
called := 0
rt := &stubAPIClient{
callFn: func(_ context.Context, _, _ string, _ any) (json.RawMessage, error) {
called++
if called <= 1 {
return json.RawMessage(`{
"code": 0,
"msg": "success",
"data": {
"minute": {
"title": ""
}
}
}`), nil
}
return json.RawMessage(`{
"code": 0,
"msg": "success",
"data": {
"minute": {
"title": "delayed title"
}
}
}`), nil
},
}
out := runMinuteGenerated(t, rt, `{
"schema": "2.0",
"header": {
"event_id": "ev_minute_retry",
"event_type": "minutes.minute.generated_v1",
"create_time": "1608725989000"
},
"event": {
"minute_token": "<doc_token_003>"
}
}`)
if called != 2 {
t.Fatalf("detail API called %d times, want 2 (1 initial + 1 retry)", called)
}
if out.Title != "delayed title" {
t.Errorf("Title = %q, want delayed title", out.Title)
}
}
func TestProcessMinutesMinuteGenerated_EmptyTitleExhaustsRetries(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
called := 0
rt := &stubAPIClient{
callFn: func(_ context.Context, _, _ string, _ any) (json.RawMessage, error) {
called++
return json.RawMessage(`{
"code": 0,
"msg": "success",
"data": {
"minute": {
"title": ""
}
}
}`), nil
},
}
out := runMinuteGenerated(t, rt, `{
"schema": "2.0",
"header": {
"event_id": "ev_minute_exhaust",
"event_type": "minutes.minute.generated_v1",
"create_time": "1608725989000"
},
"event": {
"minute_token": "<doc_token_002>"
}
}`)
wantCalls := 1 + minutesDetailMaxRetries
if called != wantCalls {
t.Fatalf("detail API called %d times, want %d", called, wantCalls)
}
if out.Title != "" {
t.Errorf("Title = %q, want empty after exhausted retries", out.Title)
}
}
func TestMinutesMinuteGenerated_PreConsumeSubscriptionLifecycle(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
def, ok := event.Lookup(eventTypeMinuteGenerated)
if !ok {
t.Fatalf("%s should be registered via Keys()", eventTypeMinuteGenerated)
}
type call struct {
method string
path string
body any
}
var calls []call
rt := &stubAPIClient{
callFn: func(_ context.Context, method, path string, body any) (json.RawMessage, error) {
calls = append(calls, call{method: method, path: path, body: body})
return json.RawMessage(`{"code":0,"msg":"success","data":{}}`), nil
},
}
cleanup, err := def.PreConsume(context.Background(), rt, nil)
if err != nil {
t.Fatalf("PreConsume error: %v", err)
}
if cleanup == nil {
t.Fatal("cleanup must not be nil")
}
if len(calls) != 1 {
t.Fatalf("calls after subscribe = %d, want 1", len(calls))
}
if calls[0].method != "POST" || calls[0].path != pathMinuteSubscribe {
t.Fatalf("subscribe call = %+v", calls[0])
}
assertSubscriptionRequest(t, calls[0].body, eventTypeMinuteGenerated)
cleanup()
if len(calls) != 2 {
t.Fatalf("calls after cleanup = %d, want 2", len(calls))
}
if calls[1].method != "POST" || calls[1].path != pathMinuteUnsubscribe {
t.Fatalf("unsubscribe call = %+v", calls[1])
}
assertSubscriptionRequest(t, calls[1].body, eventTypeMinuteGenerated)
}
func TestProcessMinutesMinuteGenerated_MalformedPayload(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
raw := &event.RawEvent{
EventType: eventTypeMinuteGenerated,
Payload: json.RawMessage(`not json`),
Timestamp: time.Now(),
}
got, err := processMinutesMinuteGenerated(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 runMinuteGenerated(t *testing.T, rt event.APIClient, payload string) MinutesMinuteGeneratedOutput {
t.Helper()
raw := &event.RawEvent{
EventType: eventTypeMinuteGenerated,
Payload: json.RawMessage(payload),
Timestamp: time.Now(),
}
got, err := processMinutesMinuteGenerated(context.Background(), rt, raw, nil)
if err != nil {
t.Fatalf("Process error: %v", err)
}
var out MinutesMinuteGeneratedOutput
if err := json.Unmarshal(got, &out); err != nil {
t.Fatalf("Process output is not valid MinutesMinuteGeneratedOutput JSON: %v\nraw=%s", err, string(got))
}
return out
}

View File

@@ -0,0 +1,33 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package minutes
import (
"context"
"fmt"
"time"
"github.com/larksuite/cli/internal/event"
)
const cleanupTimeout = 5 * time.Second
func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func(), error) {
return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func(), error) {
if rt == nil {
return nil, fmt.Errorf("runtime API client is required for pre-consume subscription")
}
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
}
}

View File

@@ -0,0 +1,42 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package minutes registers Minutes-domain EventKeys.
package minutes
import (
"reflect"
"github.com/larksuite/cli/internal/event"
)
const (
eventTypeMinuteGenerated = "minutes.minute.generated_v1"
pathMinuteSubscribe = "/open-apis/minutes/v1/minutes/subscription"
pathMinuteUnsubscribe = "/open-apis/minutes/v1/minutes/unsubscription"
pathMinuteDetailFmt = "/open-apis/minutes/v1/minutes/%s"
)
// Keys returns all Minutes-domain EventKey definitions.
func Keys() []event.KeyDefinition {
return []event.KeyDefinition{
{
Key: eventTypeMinuteGenerated,
DisplayName: "Minute generated",
Description: "Triggered when a minute has been generated",
EventType: eventTypeMinuteGenerated,
Schema: event.SchemaDef{
Custom: &event.SchemaSpec{Type: reflect.TypeOf(MinutesMinuteGeneratedOutput{})},
},
Process: processMinutesMinuteGenerated,
PreConsume: subscriptionPreConsume(eventTypeMinuteGenerated, pathMinuteSubscribe, pathMinuteUnsubscribe),
Scopes: []string{"minutes:minutes.basic:read"},
AuthTypes: []string{
"user",
},
RequiredConsoleEvents: []string{eventTypeMinuteGenerated},
},
}
}

View File

@@ -6,13 +6,17 @@ package events
import (
"github.com/larksuite/cli/events/im"
"github.com/larksuite/cli/events/minutes"
"github.com/larksuite/cli/events/vc"
"github.com/larksuite/cli/internal/event"
)
// Mail is intentionally omitted: only IM is wired up this phase.
// Mail is intentionally omitted in this phase.
func init() {
all := [][]event.KeyDefinition{
im.Keys(),
minutes.Keys(),
vc.Keys(),
}
for _, keys := range all {
for _, k := range keys {

View File

@@ -0,0 +1,77 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"encoding/json"
"strconv"
"time"
"github.com/larksuite/cli/internal/event"
)
// VCParticipantMeetingEndedOutput is the flattened shape for vc.meeting.participant_meeting_ended_v1.
type VCParticipantMeetingEndedOutput struct {
Type string `json:"type" desc:"Event type; always vc.meeting.participant_meeting_ended_v1"`
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"`
MeetingID string `json:"meeting_id,omitempty" desc:"Meeting ID" kind:"meeting_id"`
Topic string `json:"topic,omitempty" desc:"Meeting topic"`
MeetingNo string `json:"meeting_no,omitempty" desc:"Meeting number"`
StartTime string `json:"start_time,omitempty" desc:"Meeting start time in RFC3339, converted to the local timezone"`
EndTime string `json:"end_time,omitempty" desc:"Meeting end time in RFC3339, converted to the local timezone"`
CalendarEventID string `json:"calendar_event_id,omitempty" desc:"Calendar event ID associated with the meeting"`
}
func processVCParticipantMeetingEnded(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
var envelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
} `json:"header"`
Event struct {
Meeting struct {
ID string `json:"id"`
Topic string `json:"topic"`
MeetingNo string `json:"meeting_no"`
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
CalendarEventID string `json:"calendar_event_id"`
} `json:"meeting"`
} `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
}
meeting := envelope.Event.Meeting
out := &VCParticipantMeetingEndedOutput{
Type: envelope.Header.EventType,
EventID: envelope.Header.EventID,
Timestamp: envelope.Header.CreateTime,
MeetingID: meeting.ID,
Topic: meeting.Topic,
MeetingNo: meeting.MeetingNo,
StartTime: unixSecondsToLocalRFC3339(meeting.StartTime),
EndTime: unixSecondsToLocalRFC3339(meeting.EndTime),
CalendarEventID: meeting.CalendarEventID,
}
if out.Type == "" {
out.Type = raw.EventType
}
return json.Marshal(out)
}
func unixSecondsToLocalRFC3339(raw string) string {
if raw == "" {
return ""
}
secs, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return ""
}
return time.Unix(secs, 0).Local().Format(time.RFC3339)
}

View File

@@ -0,0 +1,203 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"encoding/json"
"os"
"testing"
"time"
"github.com/larksuite/cli/internal/event"
)
func TestMain(m *testing.M) {
for _, k := range Keys() {
event.RegisterKey(k)
}
os.Exit(m.Run())
}
func TestVCKeys_ProcessedMeetingEndedRegistered(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
def, ok := event.Lookup(eventTypeMeetingEnded)
if !ok {
t.Fatalf("%s should be registered via Keys()", eventTypeMeetingEnded)
}
if def.Schema.Custom == nil {
t.Error("Processed key must set Schema.Custom")
}
if def.Schema.Native != nil {
t.Error("Processed key must not set Schema.Native")
}
if def.Process == nil {
t.Error("Process must not be nil for processed key")
}
if def.PreConsume == nil {
t.Error("PreConsume must not be nil for processed key")
}
if len(def.Scopes) != 1 || def.Scopes[0] != "vc:meeting.meetingevent:read" {
t.Errorf("Scopes = %v", def.Scopes)
}
if len(def.AuthTypes) != 1 || def.AuthTypes[0] != "user" {
t.Errorf("AuthTypes = %v", def.AuthTypes)
}
}
func TestProcessVCParticipantMeetingEnded(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_vc_end_001",
"event_type": "vc.meeting.participant_meeting_ended_v1",
"create_time": "1608725989000",
"app_id": "cli_test"
},
"event": {
"meeting": {
"id": "6911188411934433028",
"topic": "my meeting",
"meeting_no": "235812466",
"start_time": "1608883322",
"end_time": "1608883899",
"calendar_event_id": "efa67a98-06a8-4df5-8559-746c8f4477ef_0"
}
}
}`
out := runMeetingEnded(t, payload)
if out.Type != eventTypeMeetingEnded {
t.Errorf("Type = %q", out.Type)
}
if out.EventID != "ev_vc_end_001" {
t.Errorf("EventID = %q", out.EventID)
}
if out.Timestamp != "1608725989000" {
t.Errorf("Timestamp = %q", out.Timestamp)
}
if out.MeetingID != "6911188411934433028" {
t.Errorf("MeetingID = %q", out.MeetingID)
}
if out.Topic != "my meeting" || out.MeetingNo != "235812466" {
t.Errorf("Topic/MeetingNo = %q/%q", out.Topic, out.MeetingNo)
}
if out.CalendarEventID != "efa67a98-06a8-4df5-8559-746c8f4477ef_0" {
t.Errorf("CalendarEventID = %q", out.CalendarEventID)
}
if want := time.Unix(1608883322, 0).Local().Format(time.RFC3339); out.StartTime != want {
t.Errorf("StartTime = %q, want %q", out.StartTime, want)
}
if want := time.Unix(1608883899, 0).Local().Format(time.RFC3339); out.EndTime != want {
t.Errorf("EndTime = %q, want %q", out.EndTime, want)
}
}
func TestProcessVCParticipantMeetingEnded_InvalidMeetingTimes(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_vc_end_002",
"event_type": "vc.meeting.participant_meeting_ended_v1",
"create_time": "1608725989001"
},
"event": {
"meeting": {
"id": "meeting_invalid_time",
"start_time": "bad",
"end_time": ""
}
}
}`
out := runMeetingEnded(t, payload)
if out.StartTime != "" || out.EndTime != "" {
t.Errorf("StartTime/EndTime = %q/%q, want empty strings", out.StartTime, out.EndTime)
}
}
func TestProcessVCParticipantMeetingEnded_MalformedPayload(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
raw := &event.RawEvent{
EventType: eventTypeMeetingEnded,
Payload: json.RawMessage(`not json`),
Timestamp: time.Now(),
}
got, err := processVCParticipantMeetingEnded(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 TestVCParticipantMeetingEnded_PreConsumeSubscriptionLifecycle(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
def, ok := event.Lookup("vc.meeting.participant_meeting_ended_v1")
if !ok {
t.Fatal("vc.meeting.participant_meeting_ended_v1 should be registered via Keys()")
}
type call struct {
method string
path string
body any
}
var calls []call
rt := &stubAPIClient{
callFn: func(_ context.Context, method, path string, body any) (json.RawMessage, error) {
calls = append(calls, call{method: method, path: path, body: body})
return json.RawMessage(`{"code":0,"msg":"success","data":{}}`), nil
},
}
cleanup, err := def.PreConsume(context.Background(), rt, nil)
if err != nil {
t.Fatalf("PreConsume error: %v", err)
}
if cleanup == nil {
t.Fatal("cleanup must not be nil")
}
if len(calls) != 1 {
t.Fatalf("calls after subscribe = %d, want 1", len(calls))
}
if calls[0].method != "POST" || calls[0].path != pathMeetingSubscribe {
t.Fatalf("subscribe call = %+v", calls[0])
}
assertSubscriptionRequest(t, calls[0].body, eventTypeMeetingEnded)
cleanup()
if len(calls) != 2 {
t.Fatalf("calls after cleanup = %d, want 2", len(calls))
}
if calls[1].method != "POST" || calls[1].path != pathMeetingUnsubscribe {
t.Fatalf("unsubscribe call = %+v", calls[1])
}
assertSubscriptionRequest(t, calls[1].body, eventTypeMeetingEnded)
}
func runMeetingEnded(t *testing.T, payload string) VCParticipantMeetingEndedOutput {
t.Helper()
raw := &event.RawEvent{
EventType: eventTypeMeetingEnded,
Payload: json.RawMessage(payload),
Timestamp: time.Now(),
}
got, err := processVCParticipantMeetingEnded(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("Process error: %v", err)
}
var out VCParticipantMeetingEndedOutput
if err := json.Unmarshal(got, &out); err != nil {
t.Fatalf("Process output is not valid VCParticipantMeetingEndedOutput JSON: %v\nraw=%s", err, string(got))
}
return out
}

33
events/vc/preconsume.go Normal file
View File

@@ -0,0 +1,33 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"fmt"
"time"
"github.com/larksuite/cli/internal/event"
)
const cleanupTimeout = 5 * time.Second
func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func(), error) {
return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func(), error) {
if rt == nil {
return nil, fmt.Errorf("runtime API client is required for pre-consume subscription")
}
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
}
}

43
events/vc/register.go Normal file
View File

@@ -0,0 +1,43 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package vc registers VC-domain EventKeys.
package vc
import (
"reflect"
"github.com/larksuite/cli/internal/event"
)
const (
eventTypeMeetingEnded = "vc.meeting.participant_meeting_ended_v1"
eventTypeNoteGenerated = "vc.note.generated_v1"
pathMeetingSubscribe = "/open-apis/vc/v1/meetings/subscription"
pathMeetingUnsubscribe = "/open-apis/vc/v1/meetings/unsubscription"
pathNoteSubscribe = "/open-apis/vc/v1/notes/subscription"
pathNoteUnsubscribe = "/open-apis/vc/v1/notes/unsubscription"
)
// Keys returns all VC-domain EventKey definitions.
func Keys() []event.KeyDefinition {
return []event.KeyDefinition{
{
Key: eventTypeMeetingEnded,
DisplayName: "Participant meeting ended",
Description: "Triggered when a meeting the current user participates in has ended",
EventType: eventTypeMeetingEnded,
Schema: event.SchemaDef{
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCParticipantMeetingEndedOutput{})},
},
Process: processVCParticipantMeetingEnded,
PreConsume: subscriptionPreConsume(eventTypeMeetingEnded, pathMeetingSubscribe, pathMeetingUnsubscribe),
Scopes: []string{"vc:meeting.meetingevent:read"},
AuthTypes: []string{
"user",
},
RequiredConsoleEvents: []string{eventTypeMeetingEnded},
},
}
}

View File

@@ -0,0 +1,30 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"encoding/json"
"reflect"
"testing"
)
type stubAPIClient struct {
callFn func(ctx context.Context, method, path string, body any) (json.RawMessage, error)
}
func (s *stubAPIClient) CallAPI(ctx context.Context, method, path string, body any) (json.RawMessage, error) {
if s.callFn == nil {
return nil, nil
}
return s.callFn(ctx, method, path, body)
}
func assertSubscriptionRequest(t *testing.T, gotBody any, wantEventType string) {
t.Helper()
want := map[string]string{"event_type": wantEventType}
if !reflect.DeepEqual(gotBody, want) {
t.Fatalf("request body = %#v, want %#v", gotBody, want)
}
}

View File

@@ -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 message receive, reactions, chat member changes, 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, 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."
metadata:
requires:
bins: ["lark-cli"]
@@ -143,3 +143,5 @@ Lark-defined semantic tags (**not** JSON Schema's standard `format`). Common val
| Topic | Reference | Coverage |
|---|---|---|
| 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) |
| VC | [`references/lark-event-vc.md`](references/lark-event-vc.md) | Catalog of 1 VC EventKey (`vc.meeting.participant_meeting_ended_v1`) + field reference + time conversion gotchas (unix seconds → local RFC3339) |
| Minutes | [`references/lark-event-minutes.md`](references/lark-event-minutes.md) | Catalog of 1 Minutes EventKey (`minutes.minute.generated_v1`) + field reference + enrichment & degradation semantics (minute detail API fills `title`; `minute_source` from event payload survives enrichment failure) |

View File

@@ -0,0 +1,54 @@
# Minutes Events
> **Prerequisite:** Read [`../SKILL.md`](../SKILL.md) first for the `event consume` essentials (commands, subprocess contract, jq usage).
## Key catalog (1)
| EventKey | Purpose |
|---|---|
| `minutes.minute.generated_v1` | A minute (妙记) has been generated |
This key uses a **Custom schema** (flat output at `.xxx`) and carries a **PreConsume hook** that auto-subscribes / unsubscribes via OAPI on first / last consumer.
## Scopes & auth
| EventKey | Scope | Auth |
|---|---|---|
| `minutes.minute.generated_v1` | `minutes:minutes.basic:read` | user |
Requires `--as user`.
## `minutes.minute.generated_v1`
### Output fields
| Field | Type | Description |
|---|---|---|
| `type` | string | Event type; always `minutes.minute.generated_v1` |
| `event_id` | string | Globally unique event ID; safe for deduplication |
| `timestamp` | string (timestamp_ms) | Event delivery time (ms timestamp string) |
| `minute_token` | string | Minute token |
| `title` | string | Minute title (enriched via detail API) |
| `minute_source` | object | Minute source metadata; only present when the source is a meeting |
| `minute_source.source_type` | string | Source type; only present when the source is a meeting (value: `meeting`) |
| `minute_source.source_entity_id` | string | Source entity ID (meeting ID); only present when the source is a meeting |
### Enrichment & degradation
The Process hook calls `GET /open-apis/minutes/v1/minutes/{minute_token}` to enrich `title`. If the detail API fails, this field is left empty — the base fields (`type`, `event_id`, `timestamp`, `minute_token`, `minute_source`) are always present.
`minute_source` is populated from the event payload directly (not the detail API), so it survives enrichment failures. Note: `minute_source` is only present when the minute originates from a meeting; for other sources (e.g. recording, local upload) this field is absent.
### Example
```bash
lark-cli event consume minutes.minute.generated_v1 --as user
# Project title and token only (skip events where enrichment failed)
lark-cli event consume minutes.minute.generated_v1 --as user \
--jq 'select(.title != "") | {minute_token, title}'
# Filter by source type
lark-cli event consume minutes.minute.generated_v1 --as user \
--jq 'select(.minute_source.source_type == "meeting") | {minute_token, title}'
```

View File

@@ -0,0 +1,50 @@
# VC Events
> **Prerequisite:** Read [`../SKILL.md`](../SKILL.md) first for the `event consume` essentials (commands, subprocess contract, jq usage).
## Key catalog (1)
| EventKey | Purpose |
|---|---|
| `vc.meeting.participant_meeting_ended_v1` | A meeting the current user participates in has ended |
This key uses a **Custom schema** (flat output at `.xxx`) and carries a **PreConsume hook** that auto-subscribes / unsubscribes via OAPI on first / last consumer.
## Scopes & auth
| EventKey | Scope | Auth |
|---|---|---|
| `vc.meeting.participant_meeting_ended_v1` | `vc:meeting.meetingevent:read` | user |
Requires `--as user`.
## `vc.meeting.participant_meeting_ended_v1`
### Output fields
| Field | Type | Description |
|---|---|---|
| `type` | string | Event type; always `vc.meeting.participant_meeting_ended_v1` |
| `event_id` | string | Globally unique event ID; safe for deduplication |
| `timestamp` | string (timestamp_ms) | Event delivery time (ms timestamp string) |
| `meeting_id` | string | Meeting ID |
| `topic` | string | Meeting topic |
| `meeting_no` | string | Meeting number |
| `start_time` | string | Meeting start time in RFC3339, converted to the local timezone |
| `end_time` | string | Meeting end time in RFC3339, converted to the local timezone |
| `calendar_event_id` | string | Calendar event ID associated with the meeting |
### Gotchas
- `start_time` / `end_time` are **not** the raw unix-seconds from OAPI — the Process hook converts them to local-timezone RFC3339. If the raw value is empty or non-numeric, the field is left empty.
- No detail API call is made; all fields come from the event payload itself.
### Example
```bash
lark-cli event consume vc.meeting.participant_meeting_ended_v1 --as user
# Project meeting topic and end time only
lark-cli event consume vc.meeting.participant_meeting_ended_v1 --as user \
--jq '{meeting: .meeting_id, topic: .topic, ended: .end_time}'
```