diff --git a/events/minutes/minute_generated.go b/events/minutes/minute_generated.go new file mode 100644 index 00000000..f4e4ec9d --- /dev/null +++ b/events/minutes/minute_generated.go @@ -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 + } +} diff --git a/events/minutes/minute_generated_test.go b/events/minutes/minute_generated_test.go new file mode 100644 index 00000000..9a0a5b13 --- /dev/null +++ b/events/minutes/minute_generated_test.go @@ -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": "", + "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": "", + "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("")) { + 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 != "" { + 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": "", + "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 != "" { + 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": "" + } + }`) + + 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": "" + } + }`) + + 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 +} diff --git a/events/minutes/preconsume.go b/events/minutes/preconsume.go new file mode 100644 index 00000000..82c329c8 --- /dev/null +++ b/events/minutes/preconsume.go @@ -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 + } +} diff --git a/events/minutes/register.go b/events/minutes/register.go new file mode 100644 index 00000000..bd0297bd --- /dev/null +++ b/events/minutes/register.go @@ -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}, + }, + } +} diff --git a/events/register.go b/events/register.go index 7ca984a0..e570da62 100644 --- a/events/register.go +++ b/events/register.go @@ -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 { diff --git a/events/vc/participant_meeting_ended.go b/events/vc/participant_meeting_ended.go new file mode 100644 index 00000000..4941b3b7 --- /dev/null +++ b/events/vc/participant_meeting_ended.go @@ -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) +} diff --git a/events/vc/participant_meeting_ended_test.go b/events/vc/participant_meeting_ended_test.go new file mode 100644 index 00000000..0989f484 --- /dev/null +++ b/events/vc/participant_meeting_ended_test.go @@ -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 +} diff --git a/events/vc/preconsume.go b/events/vc/preconsume.go new file mode 100644 index 00000000..9bd03d94 --- /dev/null +++ b/events/vc/preconsume.go @@ -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 + } +} diff --git a/events/vc/register.go b/events/vc/register.go new file mode 100644 index 00000000..938f0aed --- /dev/null +++ b/events/vc/register.go @@ -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}, + }, + } +} diff --git a/events/vc/test_helpers_test.go b/events/vc/test_helpers_test.go new file mode 100644 index 00000000..4d69d8e3 --- /dev/null +++ b/events/vc/test_helpers_test.go @@ -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) + } +} diff --git a/skills/lark-event/SKILL.md b/skills/lark-event/SKILL.md index c015fe83..3cb1bc5b 100644 --- a/skills/lark-event/SKILL.md +++ b/skills/lark-event/SKILL.md @@ -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 ` (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 ` (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) | diff --git a/skills/lark-event/references/lark-event-minutes.md b/skills/lark-event/references/lark-event-minutes.md new file mode 100644 index 00000000..537a25a8 --- /dev/null +++ b/skills/lark-event/references/lark-event-minutes.md @@ -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}' +``` diff --git a/skills/lark-event/references/lark-event-vc.md b/skills/lark-event/references/lark-event-vc.md new file mode 100644 index 00000000..7dededd9 --- /dev/null +++ b/skills/lark-event/references/lark-event-vc.md @@ -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}' +```