diff --git a/shortcuts/vc/shortcuts.go b/shortcuts/vc/shortcuts.go index 64b9132f..96b78891 100644 --- a/shortcuts/vc/shortcuts.go +++ b/shortcuts/vc/shortcuts.go @@ -10,5 +10,6 @@ func Shortcuts() []common.Shortcut { return []common.Shortcut{ VCSearch, VCNotes, + VCRecording, } } diff --git a/shortcuts/vc/vc_notes.go b/shortcuts/vc/vc_notes.go index 4aea4846..f7e4a7f7 100644 --- a/shortcuts/vc/vc_notes.go +++ b/shortcuts/vc/vc_notes.go @@ -89,44 +89,34 @@ func getPrimaryCalendarID(runtime *common.RuntimeContext) (string, error) { return calID, nil } -// fetchNoteByCalendarEventID queries notes via calendar event instance ID. -// Chain: primary calendar → mget_instance_relation_info → meeting_id → meeting.get → note_id -func fetchNoteByCalendarEventID(ctx context.Context, runtime *common.RuntimeContext, instanceID string, calendarID string) map[string]any { - errOut := runtime.IO().ErrOut - - // call mget_instance_relation_info to get meeting_id +// resolveMeetingIDsFromCalendarEvent resolves a calendar event instance to its +// associated meeting IDs via the mget_instance_relation_info API. +// Shared by +notes and +recording for the --calendar-event-ids path. +func resolveMeetingIDsFromCalendarEvent(runtime *common.RuntimeContext, instanceID string, calendarID string) ([]string, error) { data, err := runtime.DoAPIJSON(http.MethodPost, fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/mget_instance_relation_info", validate.EncodePathSegment(calendarID)), nil, map[string]any{ "instance_ids": []string{instanceID}, "need_meeting_instance_ids": true, - "need_meeting_notes": true, - "need_ai_meeting_notes": true, }) if err != nil { - return map[string]any{"calendar_event_id": instanceID, "error": fmt.Sprintf("failed to query event relation info: %v", err)} + return nil, fmt.Errorf("failed to query event relation info: %w", err) } - // parse instance_relation_infos infos, _ := data["instance_relation_infos"].([]any) if len(infos) == 0 { - return map[string]any{"calendar_event_id": instanceID, "error": "no event relation info found"} + return nil, fmt.Errorf("no event relation info found") } info, _ := infos[0].(map[string]any) - // get meeting_instance_ids - meetingIDs, _ := info["meeting_instance_ids"].([]any) - if len(meetingIDs) == 0 { - return map[string]any{"calendar_event_id": instanceID, "error": "no associated video meeting for this event"} - } - - if len(meetingIDs) > 1 { - fmt.Fprintf(errOut, "%s event %s has %d meetings, trying each\n", logPrefix, sanitizeLogValue(instanceID), len(meetingIDs)) + rawIDs, _ := info["meeting_instance_ids"].([]any) + if len(rawIDs) == 0 { + return nil, fmt.Errorf("no associated video meeting for this event") } - // try each meeting_instance_id until one has notes - for _, mid := range meetingIDs { + var ids []string + for _, mid := range rawIDs { if mid == nil { continue } @@ -139,12 +129,32 @@ func fetchNoteByCalendarEventID(ctx context.Context, runtime *common.RuntimeCont default: meetingID = fmt.Sprintf("%v", v) } + ids = append(ids, meetingID) + } + return ids, nil +} + +// fetchNoteByCalendarEventID queries notes via calendar event instance ID. +// Chain: primary calendar → mget_instance_relation_info → meeting_id → meeting.get → note_id +func fetchNoteByCalendarEventID(ctx context.Context, runtime *common.RuntimeContext, instanceID string, calendarID string) map[string]any { + errOut := runtime.IO().ErrOut + + meetingIDs, err := resolveMeetingIDsFromCalendarEvent(runtime, instanceID, calendarID) + if err != nil { + return map[string]any{"calendar_event_id": instanceID, "error": err.Error()} + } + + if len(meetingIDs) > 1 { + fmt.Fprintf(errOut, "%s event %s has %d meetings, trying each\n", logPrefix, sanitizeLogValue(instanceID), len(meetingIDs)) + } + + // try each associated meeting until one has notes + for _, meetingID := range meetingIDs { fmt.Fprintf(errOut, "%s event %s → meeting_id=%s\n", logPrefix, sanitizeLogValue(instanceID), sanitizeLogValue(meetingID)) result := fetchNoteByMeetingID(ctx, runtime, meetingID) if result["error"] == nil { return result } - // if this meeting has no notes, try next fmt.Fprintf(errOut, "%s meeting_id=%s: %s, trying next\n", logPrefix, sanitizeLogValue(meetingID), result["error"]) } return map[string]any{"calendar_event_id": instanceID, "error": "no notes found in any associated meeting"} diff --git a/shortcuts/vc/vc_recording.go b/shortcuts/vc/vc_recording.go new file mode 100644 index 00000000..480d248e --- /dev/null +++ b/shortcuts/vc/vc_recording.go @@ -0,0 +1,252 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT +// +// vc +recording — query minute_token from meeting-ids or calendar-event-ids +// +// Two mutually exclusive input modes: +// meeting-ids: recording API → extract minute_token from URL +// calendar-event-ids: primary calendar → mget_instance_relation_info → meeting_id → recording API + +package vc + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/larksuite/cli/internal/auth" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +const recordingLogPrefix = "[vc +recording]" + +var ( + scopesRecordingMeetingIDs = []string{ + "vc:record:readonly", + } + scopesRecordingCalendarEventIDs = []string{ + "vc:record:readonly", + "calendar:calendar:read", + "calendar:calendar.event:read", + } +) + +// extractMinuteToken parses minute_token from a recording URL. +// URL format: https://meetings.feishu.cn/minutes/{minute_token} +func extractMinuteToken(recordingURL string) string { + u, err := url.Parse(recordingURL) + if err != nil { + return "" + } + parts := strings.Split(strings.TrimRight(u.Path, "/"), "/") + for i, p := range parts { + if p == "minutes" && i+1 < len(parts) { + return parts[i+1] + } + } + return "" +} + +// fetchRecordingByMeetingID queries recording info for a single meeting. +func fetchRecordingByMeetingID(_ context.Context, runtime *common.RuntimeContext, meetingID string) map[string]any { + data, err := runtime.DoAPIJSON(http.MethodGet, + fmt.Sprintf("/open-apis/vc/v1/meetings/%s/recording", validate.EncodePathSegment(meetingID)), + nil, nil) + if err != nil { + return map[string]any{"meeting_id": meetingID, "error": fmt.Sprintf("failed to query recording: %v", err)} + } + + recording, _ := data["recording"].(map[string]any) + if recording == nil { + return map[string]any{"meeting_id": meetingID, "error": "no recording available for this meeting"} + } + + recordingURL, _ := recording["url"].(string) + duration, _ := recording["duration"].(string) + + result := map[string]any{"meeting_id": meetingID} + if recordingURL != "" { + result["recording_url"] = recordingURL + } + if duration != "" { + result["duration"] = duration + } + if token := extractMinuteToken(recordingURL); token != "" { + result["minute_token"] = token + } + return result +} + +// VCRecording gets meeting recording info and extracts minute_token. +var VCRecording = common.Shortcut{ + Service: "vc", + Command: "+recording", + Description: "Query minute_token from meeting-ids or calendar-event-ids", + Risk: "read", + Scopes: []string{"vc:record:readonly"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "meeting-ids", Desc: "meeting IDs, comma-separated for batch"}, + {Name: "calendar-event-ids", Desc: "calendar event instance IDs, comma-separated for batch"}, + }, + Validate: func(_ context.Context, runtime *common.RuntimeContext) error { + if err := common.ExactlyOne(runtime, "meeting-ids", "calendar-event-ids"); err != nil { + return err + } + const maxBatchSize = 50 + for _, flag := range []string{"meeting-ids", "calendar-event-ids"} { + if v := runtime.Str(flag); v != "" { + if ids := common.SplitCSV(v); len(ids) > maxBatchSize { + return output.ErrValidation("--%s: too many IDs (%d), maximum is %d", flag, len(ids), maxBatchSize) + } + } + } + var required []string + switch { + case runtime.Str("meeting-ids") != "": + required = scopesRecordingMeetingIDs + case runtime.Str("calendar-event-ids") != "": + required = scopesRecordingCalendarEventIDs + } + appID := runtime.Config.AppID + userOpenID := runtime.UserOpenId() + if appID != "" && userOpenID != "" { + stored := auth.GetStoredToken(appID, userOpenID) + if stored != nil { + if missing := auth.MissingScopes(stored.Scope, required); len(missing) > 0 { + return output.ErrWithHint(output.ExitAuth, "missing_scope", + fmt.Sprintf("missing required scope(s): %s", strings.Join(missing, ", ")), + fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(missing, " "))) + } + } + } + return nil + }, + DryRun: func(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + if ids := runtime.Str("meeting-ids"); ids != "" { + return common.NewDryRunAPI(). + GET("/open-apis/vc/v1/meetings/{meeting_id}/recording"). + Set("meeting_ids", common.SplitCSV(ids)). + Set("steps", "meeting recording API → extract minute_token from URL") + } + ids := runtime.Str("calendar-event-ids") + return common.NewDryRunAPI(). + POST("/open-apis/calendar/v4/calendars/primary"). + POST("/open-apis/calendar/v4/calendars/{calendar_id}/events/mget_instance_relation_info"). + GET("/open-apis/vc/v1/meetings/{meeting_id}/recording"). + Set("calendar_event_ids", common.SplitCSV(ids)). + Set("steps", "primary calendar → mget_instance_relation_info → meeting_id → recording API → extract minute_token") + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + errOut := runtime.IO().ErrOut + var results []any + + const batchDelay = 100 * time.Millisecond + + if ids := runtime.Str("meeting-ids"); ids != "" { + meetingIDs := common.SplitCSV(ids) + fmt.Fprintf(errOut, "%s querying %d meeting_id(s)\n", recordingLogPrefix, len(meetingIDs)) + for i, id := range meetingIDs { + if err := ctx.Err(); err != nil { + return err + } + if i > 0 { + time.Sleep(batchDelay) + } + fmt.Fprintf(errOut, "%s querying meeting_id=%s ...\n", recordingLogPrefix, sanitizeLogValue(id)) + results = append(results, fetchRecordingByMeetingID(ctx, runtime, id)) + } + } else { + instanceIDs := common.SplitCSV(runtime.Str("calendar-event-ids")) + fmt.Fprintf(errOut, "%s querying %d calendar_event_id(s)\n", recordingLogPrefix, len(instanceIDs)) + calendarID, err := getPrimaryCalendarID(runtime) + if err != nil { + return err + } + fmt.Fprintf(errOut, "%s primary calendar: %s\n", recordingLogPrefix, calendarID) + for i, instanceID := range instanceIDs { + if err := ctx.Err(); err != nil { + return err + } + if i > 0 { + time.Sleep(batchDelay) + } + fmt.Fprintf(errOut, "%s resolving calendar_event_id=%s ...\n", recordingLogPrefix, sanitizeLogValue(instanceID)) + meetingIDs, resolveErr := resolveMeetingIDsFromCalendarEvent(runtime, instanceID, calendarID) + if resolveErr != nil { + results = append(results, map[string]any{"calendar_event_id": instanceID, "error": resolveErr.Error()}) + continue + } + found := false + for _, meetingID := range meetingIDs { + fmt.Fprintf(errOut, "%s event %s → meeting_id=%s\n", recordingLogPrefix, sanitizeLogValue(instanceID), sanitizeLogValue(meetingID)) + result := fetchRecordingByMeetingID(ctx, runtime, meetingID) + if result["error"] == nil { + result["calendar_event_id"] = instanceID + results = append(results, result) + found = true + break + } + fmt.Fprintf(errOut, "%s meeting_id=%s: %s, trying next\n", recordingLogPrefix, sanitizeLogValue(meetingID), result["error"]) + } + if !found { + results = append(results, map[string]any{"calendar_event_id": instanceID, "error": "no recording found in any associated meeting"}) + } + } + } + + successCount := 0 + for _, r := range results { + m, _ := r.(map[string]any) + if m["error"] == nil { + successCount++ + } + } + fmt.Fprintf(errOut, "%s done: %d total, %d succeeded, %d failed\n", recordingLogPrefix, len(results), successCount, len(results)-successCount) + + if successCount == 0 && len(results) > 0 { + outData := map[string]any{"recordings": results} + runtime.OutFormat(outData, &output.Meta{Count: len(results)}, nil) + return output.ErrAPI(0, fmt.Sprintf("all %d queries failed", len(results)), nil) + } + + outData := map[string]any{"recordings": results} + runtime.OutFormat(outData, &output.Meta{Count: len(results)}, func(w io.Writer) { + var rows []map[string]interface{} + for _, r := range results { + m, _ := r.(map[string]any) + meetingID, _ := m["meeting_id"].(string) + row := map[string]interface{}{} + if meetingID != "" { + row["meeting_id"] = meetingID + } + if calEventID, _ := m["calendar_event_id"].(string); calEventID != "" { + row["calendar_event_id"] = calEventID + } + if errMsg, _ := m["error"].(string); errMsg != "" { + row["status"] = "FAIL" + row["error"] = errMsg + } else { + row["status"] = "OK" + if v, _ := m["minute_token"].(string); v != "" { + row["minute_token"] = v + } + if v, _ := m["duration"].(string); v != "" { + row["duration"] = v + } + } + rows = append(rows, row) + } + output.PrintTable(w, rows) + fmt.Fprintf(w, "\n%d recording(s), %d succeeded, %d failed\n", len(results), successCount, len(results)-successCount) + }) + return nil + }, +} diff --git a/shortcuts/vc/vc_recording_test.go b/shortcuts/vc/vc_recording_test.go new file mode 100644 index 00000000..c8f879e7 --- /dev/null +++ b/shortcuts/vc/vc_recording_test.go @@ -0,0 +1,774 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package vc + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" +) + +// --------------------------------------------------------------------------- +// Unit tests: extractMinuteToken +// --------------------------------------------------------------------------- + +func TestExtractMinuteToken(t *testing.T) { + tests := []struct { + name string + url string + want string + }{ + {"standard feishu URL", "https://meetings.feishu.cn/minutes/obcn37dxcftoc3656rgyejm7", "obcn37dxcftoc3656rgyejm7"}, + {"larksuite URL", "https://meetings.larksuite.com/minutes/obcn12345678", "obcn12345678"}, + {"trailing slash", "https://meetings.feishu.cn/minutes/obcntoken123/", "obcntoken123"}, + {"with query params", "https://meetings.feishu.cn/minutes/obcntoken123?from=share", "obcntoken123"}, + {"with fragment", "https://meetings.feishu.cn/minutes/obcntoken123#section", "obcntoken123"}, + {"empty URL", "", ""}, + {"no minutes path", "https://meetings.feishu.cn/other/path", ""}, + {"only domain", "https://meetings.feishu.cn", ""}, + {"minutes at end with no token", "https://meetings.feishu.cn/minutes", ""}, + {"minutes trailing slash only", "https://meetings.feishu.cn/minutes/", ""}, + {"invalid URL", "://invalid", ""}, + {"nested path after token", "https://meetings.feishu.cn/minutes/obcntoken123/extra/path", "obcntoken123"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractMinuteToken(tt.url) + if got != tt.want { + t.Errorf("extractMinuteToken(%q) = %q, want %q", tt.url, got, tt.want) + } + }) + } +} + +// --------------------------------------------------------------------------- +// Validation tests +// --------------------------------------------------------------------------- + +func TestRecording_Validation_ExactlyOne(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, defaultConfig()) + + // 没传任何 flag + err := mountAndRun(t, VCRecording, []string{"+recording", "--as", "user"}, f, nil) + if err == nil { + t.Fatal("expected validation error for no flags") + } + + // 两个 flag 都传了 + err = mountAndRun(t, VCRecording, []string{"+recording", "--meeting-ids", "m1", "--calendar-event-ids", "e1", "--as", "user"}, f, nil) + if err == nil { + t.Fatal("expected validation error for two flags") + } +} + +func TestRecording_BatchLimit_MeetingIDs(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, defaultConfig()) + ids := make([]string, 51) + for i := range ids { + ids[i] = fmt.Sprintf("m%d", i) + } + err := mountAndRun(t, VCRecording, []string{"+recording", "--meeting-ids", strings.Join(ids, ","), "--as", "user"}, f, nil) + if err == nil { + t.Fatal("expected batch limit error") + } + if !strings.Contains(err.Error(), "too many IDs") { + t.Errorf("expected 'too many IDs' error, got: %v", err) + } +} + +func TestRecording_BatchLimit_CalendarEventIDs(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, defaultConfig()) + ids := make([]string, 51) + for i := range ids { + ids[i] = fmt.Sprintf("e%d", i) + } + err := mountAndRun(t, VCRecording, []string{"+recording", "--calendar-event-ids", strings.Join(ids, ","), "--as", "user"}, f, nil) + if err == nil { + t.Fatal("expected batch limit error") + } + if !strings.Contains(err.Error(), "too many IDs") { + t.Errorf("expected 'too many IDs' error, got: %v", err) + } +} + +// --------------------------------------------------------------------------- +// DryRun tests +// --------------------------------------------------------------------------- + +func TestRecording_DryRun_MeetingIDs(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig()) + err := mountAndRun(t, VCRecording, []string{"+recording", "--meeting-ids", "m001", "--dry-run", "--as", "user"}, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + if !strings.Contains(out, "recording") { + t.Errorf("dry-run should show recording API, got: %s", out) + } + if !strings.Contains(out, "minute_token") { + t.Errorf("dry-run should mention minute_token, got: %s", out) + } +} + +func TestRecording_DryRun_CalendarEventIDs(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig()) + err := mountAndRun(t, VCRecording, []string{"+recording", "--calendar-event-ids", "evt001", "--dry-run", "--as", "user"}, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + if !strings.Contains(out, "mget_instance_relation_info") { + t.Errorf("dry-run should show mget step, got: %s", out) + } + if !strings.Contains(out, "recording") { + t.Errorf("dry-run should show recording step, got: %s", out) + } +} + +func TestRecording_DryRun_BatchIDs(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig()) + err := mountAndRun(t, VCRecording, []string{"+recording", "--meeting-ids", "m001,m002,m003", "--dry-run", "--as", "user"}, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + if !strings.Contains(out, "m001") || !strings.Contains(out, "m002") || !strings.Contains(out, "m003") { + t.Errorf("dry-run should list all meeting IDs, got: %s", out) + } +} + +// --------------------------------------------------------------------------- +// Unit tests: fetchRecordingByMeetingID via bot shortcut wrapper +// --------------------------------------------------------------------------- + +func TestFetchRecordingByMeetingID_Success(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, defaultConfig()) + warmTokenCache(t) + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/vc/v1/meetings/m001/recording", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "recording": map[string]interface{}{ + "url": "https://meetings.feishu.cn/minutes/obcntoken123", + "duration": "30000", + }, + }, + }, + }) + + s := common.Shortcut{ + Service: "test", + Command: "+fetch-recording", + AuthTypes: []string{"bot"}, + Execute: func(_ context.Context, rctx *common.RuntimeContext) error { + result := fetchRecordingByMeetingID(context.Background(), rctx, "m001") + if result["error"] != nil { + t.Errorf("unexpected error: %v", result["error"]) + } + if result["minute_token"] != "obcntoken123" { + t.Errorf("minute_token = %v, want obcntoken123", result["minute_token"]) + } + if result["duration"] != "30000" { + t.Errorf("duration = %v, want 30000", result["duration"]) + } + if result["meeting_id"] != "m001" { + t.Errorf("meeting_id = %v, want m001", result["meeting_id"]) + } + return nil + }, + } + + parent := &cobra.Command{Use: "vc"} + s.Mount(parent, f) + parent.SetArgs([]string{"+fetch-recording"}) + parent.SilenceErrors = true + parent.SilenceUsage = true + if err := parent.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestFetchRecordingByMeetingID_NoRecording(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, defaultConfig()) + warmTokenCache(t) + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/vc/v1/meetings/m002/recording", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{}, + }, + }) + + s := common.Shortcut{ + Service: "test", + Command: "+fetch-no-recording", + AuthTypes: []string{"bot"}, + Execute: func(_ context.Context, rctx *common.RuntimeContext) error { + result := fetchRecordingByMeetingID(context.Background(), rctx, "m002") + errMsg, _ := result["error"].(string) + if errMsg == "" { + t.Error("expected error for missing recording") + } + if !strings.Contains(errMsg, "no recording") { + t.Errorf("error should mention no recording, got: %s", errMsg) + } + return nil + }, + } + + parent := &cobra.Command{Use: "vc"} + s.Mount(parent, f) + parent.SetArgs([]string{"+fetch-no-recording"}) + parent.SilenceErrors = true + parent.SilenceUsage = true + if err := parent.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestFetchRecordingByMeetingID_APIError(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, defaultConfig()) + warmTokenCache(t) + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/vc/v1/meetings/m003/recording", + Body: map[string]interface{}{ + "code": 121004, "msg": "data not found", + }, + }) + + s := common.Shortcut{ + Service: "test", + Command: "+fetch-api-error", + AuthTypes: []string{"bot"}, + Execute: func(_ context.Context, rctx *common.RuntimeContext) error { + result := fetchRecordingByMeetingID(context.Background(), rctx, "m003") + errMsg, _ := result["error"].(string) + if errMsg == "" { + t.Error("expected error for API failure") + } + if !strings.Contains(errMsg, "failed to query recording") { + t.Errorf("error should mention query failure, got: %s", errMsg) + } + return nil + }, + } + + parent := &cobra.Command{Use: "vc"} + s.Mount(parent, f) + parent.SetArgs([]string{"+fetch-api-error"}) + parent.SilenceErrors = true + parent.SilenceUsage = true + if err := parent.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestFetchRecordingByMeetingID_URLWithoutMinuteToken(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, defaultConfig()) + warmTokenCache(t) + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/vc/v1/meetings/m004/recording", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "recording": map[string]interface{}{ + "url": "https://example.com/some/other/path", + "duration": "5000", + }, + }, + }, + }) + + s := common.Shortcut{ + Service: "test", + Command: "+fetch-no-token", + AuthTypes: []string{"bot"}, + Execute: func(_ context.Context, rctx *common.RuntimeContext) error { + result := fetchRecordingByMeetingID(context.Background(), rctx, "m004") + if result["error"] != nil { + t.Errorf("should not error even without minute_token: %v", result["error"]) + } + if _, exists := result["minute_token"]; exists { + t.Error("should not have minute_token for non-standard URL") + } + if result["recording_url"] != "https://example.com/some/other/path" { + t.Errorf("recording_url = %v, want the original URL", result["recording_url"]) + } + return nil + }, + } + + parent := &cobra.Command{Use: "vc"} + s.Mount(parent, f) + parent.SetArgs([]string{"+fetch-no-token"}) + parent.SilenceErrors = true + parent.SilenceUsage = true + if err := parent.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +// --------------------------------------------------------------------------- +// Unit tests: resolveMeetingIDsFromCalendarEvent +// --------------------------------------------------------------------------- + +func TestResolveMeetingIDs_TypeCoercion(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, defaultConfig()) + warmTokenCache(t) + + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/calendar/v4/calendars/cal_001/events/mget_instance_relation_info", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "instance_relation_infos": []interface{}{ + map[string]interface{}{ + "meeting_instance_ids": []interface{}{ + float64(12345678), + "string_id", + nil, + }, + }, + }, + }, + }, + }) + + s := common.Shortcut{ + Service: "test", + Command: "+resolve-test", + AuthTypes: []string{"bot"}, + Execute: func(_ context.Context, rctx *common.RuntimeContext) error { + ids, err := resolveMeetingIDsFromCalendarEvent(rctx, "evt_001", "cal_001") + if err != nil { + return err + } + if len(ids) != 2 { + t.Errorf("expected 2 IDs (nil skipped), got %d: %v", len(ids), ids) + } + if len(ids) > 0 && ids[0] != "12345678" { + t.Errorf("expected float64 coerced to string, got %q", ids[0]) + } + if len(ids) > 1 && ids[1] != "string_id" { + t.Errorf("expected string preserved, got %q", ids[1]) + } + return nil + }, + } + + parent := &cobra.Command{Use: "vc"} + s.Mount(parent, f) + parent.SetArgs([]string{"+resolve-test"}) + parent.SilenceErrors = true + parent.SilenceUsage = true + if err := parent.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestResolveMeetingIDs_NoMeetings(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, defaultConfig()) + warmTokenCache(t) + + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/calendar/v4/calendars/cal_001/events/mget_instance_relation_info", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "instance_relation_infos": []interface{}{ + map[string]interface{}{ + "meeting_instance_ids": []interface{}{}, + }, + }, + }, + }, + }) + + s := common.Shortcut{ + Service: "test", + Command: "+resolve-no-meetings", + AuthTypes: []string{"bot"}, + Execute: func(_ context.Context, rctx *common.RuntimeContext) error { + _, err := resolveMeetingIDsFromCalendarEvent(rctx, "evt_001", "cal_001") + if err == nil { + t.Error("expected error for no meetings") + } + if !strings.Contains(err.Error(), "no associated video meeting") { + t.Errorf("error should mention no meeting, got: %v", err) + } + return nil + }, + } + + parent := &cobra.Command{Use: "vc"} + s.Mount(parent, f) + parent.SetArgs([]string{"+resolve-no-meetings"}) + parent.SilenceErrors = true + parent.SilenceUsage = true + if err := parent.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestResolveMeetingIDs_NoRelationInfo(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, defaultConfig()) + warmTokenCache(t) + + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/calendar/v4/calendars/cal_001/events/mget_instance_relation_info", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "instance_relation_infos": []interface{}{}, + }, + }, + }) + + s := common.Shortcut{ + Service: "test", + Command: "+resolve-no-info", + AuthTypes: []string{"bot"}, + Execute: func(_ context.Context, rctx *common.RuntimeContext) error { + _, err := resolveMeetingIDsFromCalendarEvent(rctx, "evt_001", "cal_001") + if err == nil { + t.Error("expected error for no relation info") + } + if !strings.Contains(err.Error(), "no event relation info found") { + t.Errorf("error should mention no info, got: %v", err) + } + return nil + }, + } + + parent := &cobra.Command{Use: "vc"} + s.Mount(parent, f) + parent.SetArgs([]string{"+resolve-no-info"}) + parent.SilenceErrors = true + parent.SilenceUsage = true + if err := parent.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +// --------------------------------------------------------------------------- +// Integration tests: Execute path via bot wrapper +// --------------------------------------------------------------------------- + +// botExec runs a function within a bot shortcut context, reusing the httpmock registry. +func botExec(t *testing.T, name string, f *cmdutil.Factory, fn func(context.Context, *common.RuntimeContext) error) error { + t.Helper() + warmTokenCache(t) + s := common.Shortcut{ + Service: "test", + Command: "+" + name, + AuthTypes: []string{"bot"}, + HasFormat: true, + Execute: fn, + } + parent := &cobra.Command{Use: "vc"} + s.Mount(parent, f) + parent.SetArgs([]string{"+" + name, "--format", "json"}) + parent.SilenceErrors = true + parent.SilenceUsage = true + return parent.Execute() +} + +func TestRecording_Execute_MeetingIDs_PartialFailure(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, defaultConfig()) + + // m001 succeeds, m002 fails (API error) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/vc/v1/meetings/m001/recording", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "recording": map[string]interface{}{ + "url": "https://meetings.feishu.cn/minutes/obcnpartial1", + "duration": "10000", + }, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/vc/v1/meetings/m002/recording", + Body: map[string]interface{}{"code": 121004, "msg": "data not found"}, + }) + + err := botExec(t, "partial-fail", f, func(ctx context.Context, rctx *common.RuntimeContext) error { + r1 := fetchRecordingByMeetingID(ctx, rctx, "m001") + r2 := fetchRecordingByMeetingID(ctx, rctx, "m002") + + if r1["error"] != nil { + t.Errorf("m001 should succeed, got error: %v", r1["error"]) + } + if r1["minute_token"] != "obcnpartial1" { + t.Errorf("m001 minute_token = %v, want obcnpartial1", r1["minute_token"]) + } + if r2["error"] == nil { + t.Error("m002 should fail") + } + + // verify counting logic + results := []any{r1, r2} + successCount := 0 + for _, r := range results { + m, _ := r.(map[string]any) + if m["error"] == nil { + successCount++ + } + } + if successCount != 1 { + t.Errorf("expected 1 success, got %d", successCount) + } + return nil + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestRecording_Execute_CalendarPath_ResolveAndFetch(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, defaultConfig()) + + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/calendar/v4/calendars/cal_001/events/mget_instance_relation_info", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "instance_relation_infos": []interface{}{ + map[string]interface{}{ + "meeting_instance_ids": []interface{}{"m001"}, + }, + }, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/vc/v1/meetings/m001/recording", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "recording": map[string]interface{}{ + "url": "https://meetings.feishu.cn/minutes/obcnfromcal", + "duration": "60000", + }, + }, + }, + }) + + err := botExec(t, "cal-resolve", f, func(ctx context.Context, rctx *common.RuntimeContext) error { + ids, resolveErr := resolveMeetingIDsFromCalendarEvent(rctx, "evt_001", "cal_001") + if resolveErr != nil { + t.Fatalf("resolve failed: %v", resolveErr) + } + if len(ids) != 1 || ids[0] != "m001" { + t.Fatalf("expected [m001], got %v", ids) + } + + result := fetchRecordingByMeetingID(ctx, rctx, ids[0]) + if result["error"] != nil { + t.Errorf("fetch should succeed, got: %v", result["error"]) + } + if result["minute_token"] != "obcnfromcal" { + t.Errorf("minute_token = %v, want obcnfromcal", result["minute_token"]) + } + return nil + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestRecording_Execute_CalendarPath_MultiMeetingFallback(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, defaultConfig()) + + // calendar resolve returns two meetings: m001 (no recording) and m002 (has recording) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/calendar/v4/calendars/cal_001/events/mget_instance_relation_info", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "instance_relation_infos": []interface{}{ + map[string]interface{}{ + "meeting_instance_ids": []interface{}{"m001", "m002"}, + }, + }, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/vc/v1/meetings/m001/recording", + Body: map[string]interface{}{"code": 121004, "msg": "data not found"}, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/vc/v1/meetings/m002/recording", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "recording": map[string]interface{}{ + "url": "https://meetings.feishu.cn/minutes/obcnfallback", + "duration": "45000", + }, + }, + }, + }) + + err := botExec(t, "cal-fallback", f, func(ctx context.Context, rctx *common.RuntimeContext) error { + ids, resolveErr := resolveMeetingIDsFromCalendarEvent(rctx, "evt_001", "cal_001") + if resolveErr != nil { + t.Fatalf("resolve failed: %v", resolveErr) + } + if len(ids) != 2 { + t.Fatalf("expected 2 meeting IDs, got %d", len(ids)) + } + + // simulate fallback: try each until success + var found bool + for _, meetingID := range ids { + result := fetchRecordingByMeetingID(ctx, rctx, meetingID) + if result["error"] == nil { + if result["minute_token"] != "obcnfallback" { + t.Errorf("minute_token = %v, want obcnfallback", result["minute_token"]) + } + found = true + break + } + } + if !found { + t.Error("expected fallback to succeed on m002") + } + return nil + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestRecording_Execute_AllFailed_ErrorMessage(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, defaultConfig()) + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/vc/v1/meetings/m001/recording", + Body: map[string]interface{}{"code": 121004, "msg": "data not found"}, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/vc/v1/meetings/m002/recording", + Body: map[string]interface{}{"code": 121005, "msg": "no permission"}, + }) + + err := botExec(t, "all-fail", f, func(ctx context.Context, rctx *common.RuntimeContext) error { + r1 := fetchRecordingByMeetingID(ctx, rctx, "m001") + r2 := fetchRecordingByMeetingID(ctx, rctx, "m002") + + if r1["error"] == nil || r2["error"] == nil { + t.Error("both should fail") + } + e1, _ := r1["error"].(string) + e2, _ := r2["error"].(string) + if !strings.Contains(e1, "data not found") { + t.Errorf("m001 error should contain API message, got: %s", e1) + } + if !strings.Contains(e2, "no permission") { + t.Errorf("m002 error should contain API message, got: %s", e2) + } + if r1["meeting_id"] != "m001" { + t.Errorf("error result should preserve meeting_id, got: %v", r1["meeting_id"]) + } + return nil + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestRecording_Execute_EmptyURL(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, defaultConfig()) + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/vc/v1/meetings/m001/recording", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "recording": map[string]interface{}{ + "url": "", + "duration": "1000", + }, + }, + }, + }) + + err := botExec(t, "empty-url", f, func(ctx context.Context, rctx *common.RuntimeContext) error { + result := fetchRecordingByMeetingID(ctx, rctx, "m001") + if result["error"] != nil { + t.Errorf("empty URL should not cause error: %v", result["error"]) + } + if _, exists := result["minute_token"]; exists { + t.Error("empty URL should not produce minute_token") + } + if _, exists := result["recording_url"]; exists { + t.Error("empty URL should not produce recording_url") + } + if result["duration"] != "1000" { + t.Errorf("duration should be preserved, got: %v", result["duration"]) + } + return nil + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestRecording_Execute_RecordingGenerating(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, defaultConfig()) + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/vc/v1/meetings/m001/recording", + Body: map[string]interface{}{"code": 124002, "msg": "recording generating"}, + }) + + err := botExec(t, "generating", f, func(ctx context.Context, rctx *common.RuntimeContext) error { + result := fetchRecordingByMeetingID(ctx, rctx, "m001") + errMsg, _ := result["error"].(string) + if errMsg == "" { + t.Error("should return error for generating recording") + } + if !strings.Contains(errMsg, "recording generating") { + t.Errorf("error should mention recording generating, got: %s", errMsg) + } + return nil + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/skills/lark-vc/SKILL.md b/skills/lark-vc/SKILL.md index c210d860..a6712fe9 100644 --- a/skills/lark-vc/SKILL.md +++ b/skills/lark-vc/SKILL.md @@ -71,7 +71,7 @@ Meeting (视频会议) │ ├── MainDoc (主纪要文档) │ ├── VerbatimDoc (逐字稿) │ └── SharedDoc (会中共享文档) -└── Minutes (妙记) +└── Minutes (妙记) ← minute_token 标识,+recording 从 meeting_id 获取 ├── Transcript (文字记录) ├── Summary (总结) ├── Todos (待办) @@ -94,6 +94,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli vc + [flags]`)。 |----------|------| | [`+search`](references/lark-vc-search.md) | Search meeting records (requires at least one filter) | | [`+notes`](references/lark-vc-notes.md) | Query meeting notes (via meeting-ids, minute-tokens, or calendar-event-ids) | +| [`+recording`](references/lark-vc-recording.md) | Query minute_token from meeting-ids or calendar-event-ids | ## API Resources @@ -128,5 +129,7 @@ lark-cli vc meeting get --params '{"meeting_id": "", "with_participa | `+notes --meeting-ids` | `vc:meeting.meetingevent:read`、`vc:note:read` | | `+notes --minute-tokens` | `vc:note:read`、`minutes:minutes:readonly`、`minutes:minutes.artifacts:read`、`minutes:minutes.transcript:export` | | `+notes --calendar-event-ids` | `calendar:calendar:read`、`calendar:calendar.event:read`、`vc:meeting.meetingevent:read`、`vc:note:read` | +| `+recording --meeting-ids` | `vc:record:readonly` | +| `+recording --calendar-event-ids` | `vc:record:readonly`、`calendar:calendar:read`、`calendar:calendar.event:read` | | `+search` | `vc:meeting.search:read` | | `meeting.get` | `vc:meeting.meetingevent:read` | diff --git a/skills/lark-vc/references/lark-vc-recording.md b/skills/lark-vc/references/lark-vc-recording.md new file mode 100644 index 00000000..e96f2c22 --- /dev/null +++ b/skills/lark-vc/references/lark-vc-recording.md @@ -0,0 +1,127 @@ + +# vc +recording + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +通过 meeting_id 或 calendar_event_id 查询对应的 minute_token。这是 VC 域和 Minutes 域之间的桥梁命令。只读操作。 + +本 skill 对应 shortcut:`lark-cli vc +recording`。 + +## 命令 + +```bash +# 通过会议 ID 查询(逗号分隔支持批量,最多 50 个) +lark-cli vc +recording --meeting-ids 69xxxxxxxxxxxxx28 +lark-cli vc +recording --meeting-ids 69xxxxxxxxxxxxx28,69xxxxxxxxxxxxx29 + +# 通过日程事件 ID 查询 +lark-cli vc +recording --calendar-event-ids xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx_0 + +# 输出格式 +lark-cli vc +recording --meeting-ids 69xxxxxxxxxxxxx28 --format json + +# 预览 API 调用 +lark-cli vc +recording --meeting-ids 69xxxxxxxxxxxxx28 --dry-run +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--meeting-ids ` | 二选一 | 会议 ID,逗号分隔支持批量 | +| `--calendar-event-ids ` | 二选一 | 日程事件 ID,逗号分隔支持批量 | +| `--format ` | 否 | 输出格式:json (默认) / pretty / table / ndjson / csv | +| `--dry-run` | 否 | 预览 API 调用,不执行 | + +## 核心约束 + +### 1. 两种参数互斥 + +每次只能指定一种输入方式。同时传入会报错。 + +### 2. 仅支持 user 身份 + +该命令仅支持 `user` 身份,使用前需完成 `lark-cli auth login`。user token 只能查自己有权限的录制。 + +### 3. 批量上限 + +每次最多传入 50 个 ID。 + +### 4. 录制必须已完成 + +录制必须完成生成后才能查询。时长 < 5 秒的录制可能不会生成文件。 + +## 输出结果 + +返回 `recordings` 数组,每条记录包含: + +| 字段 | 说明 | +|------|------| +| `meeting_id` | 会议 ID | +| `calendar_event_id` | 日历事件 ID(仅 `--calendar-event-ids` 路径) | +| `minute_token` | 从录制 URL 中解析的妙记 Token | +| `recording_url` | 录制 URL | +| `duration` | 录制时长(毫秒) | +| `error` | 错误信息(仅查询失败时存在) | + +## 如何获取输入参数 + +| 输入参数 | 获取方式 | +|---------|---------| +| `meeting_id` | `vc +search` 搜索历史会议 → 结果中的 `id` 字段 | +| `calendar_event_id` | `calendar +agenda` 查看日程 → 结果中的 `event_id` 字段 | + +## Agent 组合场景 + +### 场景 1:知道 meeting_id,想下载录制 + +```bash +vc +recording --meeting-ids xxx → minute_token +minutes +download --minute-token +``` + +### 场景 2:知道 meeting_id,想获取完整纪要(含 AI 产物) + +```bash +vc +recording --meeting-ids xxx → minute_token +vc +notes --minute-tokens +``` + +### 场景 3:搜索会议 → 获取录制 → 下载 + +```bash +vc +search --query "周会" --start yesterday → meeting_ids +vc +recording --meeting-ids → minute_tokens +minutes +download --minute-token +``` + +### 场景 4:从日历事件获取录制 + +```bash +vc +recording --calendar-event-ids → minute_token +minutes +download --minute-token +``` + +## 常见错误与排查 + +| 错误现象 | 根本原因 | 解决方案 | +|---------|---------|---------| +| `exactly one of ... is required` | 未传入参数或同时传了多种 | 只指定一种输入方式 | +| `no recording available` | 该会议无录制或录制未完成 | 确认会议已结束且开启了录制 | +| `121005 no permission` | 无权查看该会议录制 | 确认是会议参与者或有录制权限 | +| `124002 recording generating` | 录制文件仍在生成中 | 等待录制完成后重试 | +| `missing required scope(s)` | 权限不足 | 按提示运行 `auth login --scope` | + +## 提示 + +- 默认使用 `--format json` 输出,Agent 更擅长解析 JSON 数据。 +- 排查参数与请求结构时优先使用 `--dry-run`。 +- `minute_token` 从录制 URL 尾段解析(`https://meetings.feishu.cn/minutes/{minute_token}`)。 +- 拿到 `minute_token` 后可直接传给 `minutes +download` 或 `vc +notes --minute-tokens`。 + +## 参考 + +- [lark-vc](../SKILL.md) — 视频会议全部命令 +- [lark-vc-search](lark-vc-search.md) — 搜索历史会议(获取 meeting_id) +- [lark-vc-notes](lark-vc-notes.md) — 获取会议纪要 +- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数