diff --git a/shortcuts/minutes/minutes_search.go b/shortcuts/minutes/minutes_search.go new file mode 100644 index 000000000..832a77c9e --- /dev/null +++ b/shortcuts/minutes/minutes_search.go @@ -0,0 +1,347 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package minutes + +import ( + "context" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" + "unicode/utf8" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +const ( + defaultMinutesSearchPageSize = 15 + maxMinutesSearchPageSize = 30 + maxMinutesSearchQueryLen = 50 +) + +// parseTimeRange normalizes --start and --end into RFC3339 timestamps. +func parseTimeRange(runtime *common.RuntimeContext) (string, string, error) { + start := strings.TrimSpace(runtime.Str("start")) + end := strings.TrimSpace(runtime.Str("end")) + if start == "" && end == "" { + return "", "", nil + } + + var startTime, endTime string + if start != "" { + parsed, err := toRFC3339(start) + if err != nil { + return "", "", output.ErrValidation("--start: %v", err) + } + startTime = parsed + } + if end != "" { + parsed, err := toRFC3339(end, "end") + if err != nil { + return "", "", output.ErrValidation("--end: %v", err) + } + endTime = parsed + } + if startTime != "" && endTime != "" { + st, err := time.Parse(time.RFC3339, startTime) + if err != nil { + return "", "", fmt.Errorf("parse normalized --start: %w", err) + } + et, err := time.Parse(time.RFC3339, endTime) + if err != nil { + return "", "", fmt.Errorf("parse normalized --end: %w", err) + } + if st.After(et) { + return "", "", output.ErrValidation("--start (%s) is after --end (%s)", start, end) + } + } + return startTime, endTime, nil +} + +// toRFC3339 converts a supported CLI time input into an RFC3339 timestamp. +func toRFC3339(input string, hint ...string) (string, error) { + ts, err := common.ParseTime(input, hint...) + if err != nil { + return "", err + } + sec, err := strconv.ParseInt(ts, 10, 64) + if err != nil { + return "", fmt.Errorf("invalid timestamp %q: %w", ts, err) + } + return time.Unix(sec, 0).Format(time.RFC3339), nil +} + +// resolveUserIDs expands special user identifiers and removes duplicates. +func resolveUserIDs(flagName string, ids []string, runtime *common.RuntimeContext) ([]string, error) { + if len(ids) == 0 { + return nil, nil + } + currentUserID := runtime.UserOpenId() + seen := make(map[string]struct{}, len(ids)) + out := make([]string, 0, len(ids)) + for _, id := range ids { + if strings.EqualFold(id, "me") { + if currentUserID == "" { + return nil, output.ErrValidation("%s: \"me\" requires a logged-in user with a resolvable open_id", flagName) + } + id = currentUserID + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + return out, nil +} + +// buildTimeFilter builds the create_time filter block for the API request. +func buildTimeFilter(startTime, endTime string) map[string]interface{} { + if startTime == "" && endTime == "" { + return nil + } + timeRange := map[string]interface{}{} + if startTime != "" { + timeRange["start_time"] = startTime + } + if endTime != "" { + timeRange["end_time"] = endTime + } + return timeRange +} + +// buildMinutesSearchFilter builds the filter object for the API request body. +func buildMinutesSearchFilter(runtime *common.RuntimeContext, startTime, endTime string) (map[string]interface{}, error) { + filter := map[string]interface{}{} + + ownerIDs, err := resolveUserIDs("--owner-ids", common.SplitCSV(runtime.Str("owner-ids")), runtime) + if err != nil { + return nil, err + } + if len(ownerIDs) > 0 { + filter["owner_ids"] = ownerIDs + } + + participantIDs, err := resolveUserIDs("--participant-ids", common.SplitCSV(runtime.Str("participant-ids")), runtime) + if err != nil { + return nil, err + } + if len(participantIDs) > 0 { + filter["participant_ids"] = participantIDs + } + + if timeRange := buildTimeFilter(startTime, endTime); timeRange != nil { + filter["create_time"] = timeRange + } + + if len(filter) == 0 { + return nil, nil + } + return filter, nil +} + +// buildMinutesSearchBody builds the POST body for the minutes search API. +func buildMinutesSearchBody(runtime *common.RuntimeContext, startTime, endTime string) (map[string]interface{}, error) { + body := map[string]interface{}{} + + if q := strings.TrimSpace(runtime.Str("query")); q != "" { + body["query"] = q + } + + filter, err := buildMinutesSearchFilter(runtime, startTime, endTime) + if err != nil { + return nil, err + } + if filter != nil { + body["filter"] = filter + } + + return body, nil +} + +// buildMinutesSearchParams builds the query parameters for the search request. +func buildMinutesSearchParams(runtime *common.RuntimeContext) map[string]interface{} { + params := map[string]interface{}{} + + pageSize := strings.TrimSpace(runtime.Str("page-size")) + if pageSize == "" { + pageSize = fmt.Sprintf("%d", defaultMinutesSearchPageSize) + } + params["page_size"] = pageSize + + if pageToken := strings.TrimSpace(runtime.Str("page-token")); pageToken != "" { + params["page_token"] = pageToken + } + + return params +} + +// minuteSearchItems extracts the result items from the API response payload. +func minuteSearchItems(data map[string]interface{}) []interface{} { + return common.GetSlice(data, "items") +} + +// minuteSearchToken extracts the minute token from a search result item. +func minuteSearchToken(item map[string]interface{}) string { + return common.GetString(item, "token") +} + +// minuteSearchDisplayInfo extracts the display_info field from a search result item. +func minuteSearchDisplayInfo(item map[string]interface{}) string { + return common.GetString(item, "display_info") +} + +// minuteSearchDescription extracts the description field from a search result item. +func minuteSearchDescription(item map[string]interface{}) string { + meta := common.GetMap(item, "meta_data") + return common.GetString(meta, "description") +} + +// minuteSearchAppLink extracts the app link from a search result item. +func minuteSearchAppLink(item map[string]interface{}) string { + meta := common.GetMap(item, "meta_data") + return common.GetString(meta, "app_link") +} + +// minuteSearchAvatar extracts the avatar URL from a search result item. +func minuteSearchAvatar(item map[string]interface{}) string { + meta := common.GetMap(item, "meta_data") + return common.GetString(meta, "avatar") +} + +// buildMinuteSearchRows converts API items into pretty output rows. +func buildMinuteSearchRows(items []interface{}) []map[string]interface{} { + rows := make([]map[string]interface{}, 0, len(items)) + for _, raw := range items { + item, _ := raw.(map[string]interface{}) + if item == nil { + continue + } + rows = append(rows, map[string]interface{}{ + "token": minuteSearchToken(item), + "display_info": common.TruncateStr(minuteSearchDisplayInfo(item), 40), + "description": common.TruncateStr(minuteSearchDescription(item), 40), + "app_link": common.TruncateStr(minuteSearchAppLink(item), 80), + "avatar": common.TruncateStr(minuteSearchAvatar(item), 80), + }) + } + return rows +} + +// MinutesSearch searches minutes by keyword, owners, participants, and time range. +var MinutesSearch = common.Shortcut{ + Service: "minutes", + Command: "+search", + Description: "Search minutes by keyword, owners, participants, and time range", + Risk: "read", + Scopes: []string{"minutes:minutes.search:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "query", Desc: "search keyword"}, + {Name: "owner-ids", Desc: "owner open_id list, comma-separated (use \"me\" for current user)"}, + {Name: "participant-ids", Desc: "participant open_id list, comma-separated (use \"me\" for current user)"}, + {Name: "start", Desc: "time lower bound (ISO 8601 or YYYY-MM-DD)"}, + {Name: "end", Desc: "time upper bound (ISO 8601 or YYYY-MM-DD)"}, + {Name: "page-token", Desc: "page token for next page"}, + {Name: "page-size", Default: "15", Desc: "page size, 1-30 (default 15)"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, _, err := parseTimeRange(runtime); err != nil { + return err + } + if q := strings.TrimSpace(runtime.Str("query")); q != "" && utf8.RuneCountInString(q) > maxMinutesSearchQueryLen { + return output.ErrValidation("--query: length must be between 1 and 50 characters") + } + if _, err := common.ValidatePageSize(runtime, "page-size", defaultMinutesSearchPageSize, 1, maxMinutesSearchPageSize); err != nil { + return err + } + ownerIDs, err := resolveUserIDs("--owner-ids", common.SplitCSV(runtime.Str("owner-ids")), runtime) + if err != nil { + return err + } + for _, id := range ownerIDs { + if _, err := common.ValidateUserID(id); err != nil { + return err + } + } + participantIDs, err := resolveUserIDs("--participant-ids", common.SplitCSV(runtime.Str("participant-ids")), runtime) + if err != nil { + return err + } + for _, id := range participantIDs { + if _, err := common.ValidateUserID(id); err != nil { + return err + } + } + for _, flag := range []string{"query", "owner-ids", "participant-ids", "start", "end"} { + if strings.TrimSpace(runtime.Str(flag)) != "" { + return nil + } + } + return common.FlagErrorf("specify at least one of --query, --owner-ids, --participant-ids, --start, or --end") + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + startTime, endTime, err := parseTimeRange(runtime) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + params := buildMinutesSearchParams(runtime) + body, err := buildMinutesSearchBody(runtime, startTime, endTime) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + dryRun := common.NewDryRunAPI(). + POST("/open-apis/minutes/v1/minutes/search") + if len(params) > 0 { + dryRun.Params(params) + } + return dryRun.Body(body) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + startTime, endTime, err := parseTimeRange(runtime) + if err != nil { + return err + } + body, err := buildMinutesSearchBody(runtime, startTime, endTime) + if err != nil { + return err + } + + data, err := runtime.CallAPI(http.MethodPost, "/open-apis/minutes/v1/minutes/search", buildMinutesSearchParams(runtime), body) + if err != nil { + return err + } + if data == nil { + data = map[string]interface{}{} + } + + items := minuteSearchItems(data) + hasMore, _ := data["has_more"].(bool) + pageToken, _ := data["page_token"].(string) + rows := buildMinuteSearchRows(items) + + outData := map[string]interface{}{ + "items": items, + "total": data["total"], + "has_more": data["has_more"], + "page_token": data["page_token"], + } + + runtime.OutFormat(outData, &output.Meta{Count: len(rows)}, func(w io.Writer) { + if len(rows) == 0 { + fmt.Fprintln(w, "No minutes.") + return + } + output.PrintTable(w, rows) + }) + if hasMore && runtime.Format != "json" && runtime.Format != "" { + fmt.Fprintf(runtime.IO().Out, "\n(more available, page_token: %s)\n", pageToken) + } + return nil + }, +} diff --git a/shortcuts/minutes/minutes_search_test.go b/shortcuts/minutes/minutes_search_test.go new file mode 100644 index 000000000..f7a28415d --- /dev/null +++ b/shortcuts/minutes/minutes_search_test.go @@ -0,0 +1,691 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package minutes + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" + "github.com/spf13/cobra" +) + +// newMinutesSearchTestCommand builds a command with the flags used by minutes search tests. +func newMinutesSearchTestCommand() *cobra.Command { + cmd := &cobra.Command{Use: "test"} + cmd.Flags().String("query", "", "") + cmd.Flags().String("owner-ids", "", "") + cmd.Flags().String("participant-ids", "", "") + cmd.Flags().String("start", "", "") + cmd.Flags().String("end", "", "") + cmd.Flags().String("page-token", "", "") + cmd.Flags().String("page-size", "15", "") + return cmd +} + +// configWithoutUserOpenID returns a test config without a resolvable user open_id. +func configWithoutUserOpenID() *core.CliConfig { + cfg := defaultConfig() + cfg.UserOpenId = "" + return cfg +} + +// TestMinutesSearchParseTimeRange verifies valid time inputs are normalized. +func TestMinutesSearchParseTimeRange(t *testing.T) { + t.Parallel() + + cmd := newMinutesSearchTestCommand() + _ = cmd.Flags().Set("start", "2026-03-24") + _ = cmd.Flags().Set("end", "2026-03-25") + + runtime := common.TestNewRuntimeContext(cmd, defaultConfig()) + start, end, err := parseTimeRange(runtime) + if err != nil { + t.Fatalf("parseTimeRange() unexpected error: %v", err) + } + if start == "" || end == "" { + t.Fatalf("expected non-empty start/end, got %q %q", start, end) + } +} + +// TestMinutesSearchParseTimeRangeErrors verifies invalid time inputs return validation errors. +func TestMinutesSearchParseTimeRangeErrors(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + start string + end string + wantMessage string + }{ + {name: "invalid start", start: "bad-start", wantMessage: "--start:"}, + {name: "invalid end", end: "bad-end", wantMessage: "--end:"}, + {name: "start after end", start: "2026-03-26", end: "2026-03-25", wantMessage: "is after --end"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + cmd := newMinutesSearchTestCommand() + if tt.start != "" { + _ = cmd.Flags().Set("start", tt.start) + } + if tt.end != "" { + _ = cmd.Flags().Set("end", tt.end) + } + + _, _, err := parseTimeRange(common.TestNewRuntimeContext(cmd, defaultConfig())) + if err == nil { + t.Fatal("expected parseTimeRange error") + } + if !strings.Contains(err.Error(), tt.wantMessage) { + t.Fatalf("error = %v, want %q", err, tt.wantMessage) + } + }) + } +} + +// TestBuildMinutesSearchParams verifies request params and body fields are assembled correctly. +func TestBuildMinutesSearchParams(t *testing.T) { + t.Parallel() + + cmd := newMinutesSearchTestCommand() + _ = cmd.Flags().Set("query", "budget") + _ = cmd.Flags().Set("owner-ids", "ou_owner,ou_owner_2") + _ = cmd.Flags().Set("participant-ids", "ou_c") + _ = cmd.Flags().Set("page-size", "5") + _ = cmd.Flags().Set("page-token", "next_page") + + runtime := common.TestNewRuntimeContext(cmd, defaultConfig()) + params := buildMinutesSearchParams(runtime) + body, err := buildMinutesSearchBody(runtime, "2026-03-24T00:00:00Z", "2026-03-25T00:00:00Z") + if err != nil { + t.Fatalf("buildMinutesSearchBody() unexpected error: %v", err) + } + + if got, _ := params["page_size"].(string); got != "5" { + t.Fatalf("page_size = %q, want 5", got) + } + if got, _ := params["page_token"].(string); got != "next_page" { + t.Fatalf("page_token = %q, want next_page", got) + } + if body["query"] != "budget" { + t.Fatalf("body.query = %v, want budget", body["query"]) + } + filter, _ := body["filter"].(map[string]interface{}) + if filter == nil { + t.Fatalf("body.filter = nil, want filter object") + } + owners, _ := filter["owner_ids"].([]string) + if len(owners) != 2 || owners[0] != "ou_owner" || owners[1] != "ou_owner_2" { + t.Fatalf("owner_ids = %v, want [ou_owner ou_owner_2]", filter["owner_ids"]) + } + participants, _ := filter["participant_ids"].([]string) + if len(participants) != 1 || participants[0] != "ou_c" { + t.Fatalf("participant_ids = %v, want [ou_c]", filter["participant_ids"]) + } + createTime, _ := filter["create_time"].(map[string]interface{}) + if createTime == nil { + t.Fatalf("create_time = nil, want time range") + } + if createTime["start_time"] != "2026-03-24T00:00:00Z" { + t.Fatalf("start_time = %v", createTime["start_time"]) + } + if createTime["end_time"] != "2026-03-25T00:00:00Z" { + t.Fatalf("end_time = %v", createTime["end_time"]) + } +} + +// TestBuildMinutesSearchParamsDefaultPageSize verifies the default page size is applied. +func TestBuildMinutesSearchParamsDefaultPageSize(t *testing.T) { + t.Parallel() + + cmd := newMinutesSearchTestCommand() + + params := buildMinutesSearchParams(common.TestNewRuntimeContext(cmd, defaultConfig())) + if got, _ := params["page_size"].(string); got != "15" { + t.Fatalf("page_size = %q, want 15", got) + } +} + +// TestResolveUserIDs verifies me expansion, deduplication, and nil handling. +func TestResolveUserIDs(t *testing.T) { + t.Parallel() + + cmd := &cobra.Command{Use: "test"} + runtime := common.TestNewRuntimeContext(cmd, defaultConfig()) + + got, err := resolveUserIDs("--owner-ids", []string{"me"}, runtime) + if err != nil { + t.Fatalf("resolveUserIDs([me]) unexpected error: %v", err) + } + if len(got) != 1 || got[0] != "ou_testuser" { + t.Fatalf("resolveUserIDs([me]) = %v, want [ou_testuser]", got) + } + + got, err = resolveUserIDs("--owner-ids", []string{"ou_other", "me", "Me"}, runtime) + if err != nil { + t.Fatalf("resolveUserIDs([ou_other, me, Me]) unexpected error: %v", err) + } + if len(got) != 2 || got[0] != "ou_other" || got[1] != "ou_testuser" { + t.Fatalf("resolveUserIDs([ou_other, me, Me]) = %v, want [ou_other ou_testuser]", got) + } + + got, err = resolveUserIDs("--owner-ids", nil, runtime) + if err != nil { + t.Fatalf("resolveUserIDs(nil) unexpected error: %v", err) + } + if got != nil { + t.Fatalf("resolveUserIDs(nil) = %v, want nil", got) + } +} + +// TestBuildTimeFilter verifies time filters are only populated for provided bounds. +func TestBuildTimeFilter(t *testing.T) { + t.Parallel() + + if got := buildTimeFilter("", ""); got != nil { + t.Fatalf("buildTimeFilter('', '') = %v, want nil", got) + } + if got := buildTimeFilter("2026-03-24T00:00:00Z", ""); got["start_time"] != "2026-03-24T00:00:00Z" { + t.Fatalf("start_time = %v", got["start_time"]) + } + if got := buildTimeFilter("", "2026-03-25T00:00:00Z"); got["end_time"] != "2026-03-25T00:00:00Z" { + t.Fatalf("end_time = %v", got["end_time"]) + } +} + +// TestMinutesSearchValidationMeOwnerID verifies owner-ids accepts me when open_id is available. +func TestMinutesSearchValidationMeOwnerID(t *testing.T) { + cmd := newMinutesSearchTestCommand() + _ = cmd.Flags().Set("owner-ids", "me") + + runtime := common.TestNewRuntimeContext(cmd, defaultConfig()) + err := MinutesSearch.Validate(context.Background(), runtime) + if err != nil { + t.Fatalf("expected no error for --owner-ids me, got: %v", err) + } +} + +// TestMinutesSearchValidationMeRequiresResolvableUser verifies me fails without a resolvable open_id. +func TestMinutesSearchValidationMeRequiresResolvableUser(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + flag string + }{ + {name: "owner ids", flag: "owner-ids"}, + {name: "participant ids", flag: "participant-ids"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + cmd := newMinutesSearchTestCommand() + _ = cmd.Flags().Set(tt.flag, "me") + + runtime := common.TestNewRuntimeContext(cmd, configWithoutUserOpenID()) + err := MinutesSearch.Validate(context.Background(), runtime) + if err == nil { + t.Fatal("expected validation error for unresolved me") + } + if !strings.Contains(err.Error(), "resolvable open_id") { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} + +// TestBuildMinutesSearchFilterMeExpansion verifies me is expanded inside the request filter. +func TestBuildMinutesSearchFilterMeExpansion(t *testing.T) { + t.Parallel() + + cmd := newMinutesSearchTestCommand() + _ = cmd.Flags().Set("owner-ids", "me,ou_other") + _ = cmd.Flags().Set("participant-ids", "me") + + runtime := common.TestNewRuntimeContext(cmd, defaultConfig()) + body, err := buildMinutesSearchBody(runtime, "", "") + if err != nil { + t.Fatalf("buildMinutesSearchBody() unexpected error: %v", err) + } + + filter, _ := body["filter"].(map[string]interface{}) + if filter == nil { + t.Fatal("body.filter = nil, want filter object") + } + owners, _ := filter["owner_ids"].([]string) + if len(owners) != 2 || owners[0] != "ou_testuser" || owners[1] != "ou_other" { + t.Fatalf("owner_ids = %v, want [ou_testuser ou_other]", owners) + } + participants, _ := filter["participant_ids"].([]string) + if len(participants) != 1 || participants[0] != "ou_testuser" { + t.Fatalf("participant_ids = %v, want [ou_testuser]", participants) + } +} + +// TestMinuteSearchItems verifies items extraction from the search response payload. +func TestMinuteSearchItems(t *testing.T) { + t.Parallel() + + items := minuteSearchItems(map[string]interface{}{ + "items": []interface{}{map[string]interface{}{"minute_token": "tok_1"}}, + }) + if len(items) != 1 { + t.Fatalf("minuteSearchItems() len = %d, want 1", len(items)) + } + + if got := minuteSearchItems(map[string]interface{}{}); got != nil { + t.Fatalf("minuteSearchItems() = %v, want nil", got) + } +} + +// TestMinutesSearchValidationNoFilter verifies at least one filter is required. +func TestMinutesSearchValidationNoFilter(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, defaultConfig()) + err := mountAndRun(t, MinutesSearch, []string{"+search", "--as", "user"}, f, nil) + if err == nil { + t.Fatal("expected validation error for empty filters") + } + if !strings.Contains(err.Error(), "specify at least one") { + t.Fatalf("unexpected error: %v", err) + } +} + +// TestMinutesSearchValidationInvalidParticipantID verifies participant IDs must be valid open_ids. +func TestMinutesSearchValidationInvalidParticipantID(t *testing.T) { + cmd := newMinutesSearchTestCommand() + _ = cmd.Flags().Set("participant-ids", "user_123") + + runtime := common.TestNewRuntimeContext(cmd, defaultConfig()) + err := MinutesSearch.Validate(context.Background(), runtime) + if err == nil { + t.Fatal("expected invalid user ID error") + } +} + +// TestMinutesSearchValidationInvalidOwnerID verifies owner IDs must be valid open_ids. +func TestMinutesSearchValidationInvalidOwnerID(t *testing.T) { + cmd := newMinutesSearchTestCommand() + _ = cmd.Flags().Set("owner-ids", "user_123") + + runtime := common.TestNewRuntimeContext(cmd, defaultConfig()) + err := MinutesSearch.Validate(context.Background(), runtime) + if err == nil { + t.Fatal("expected invalid owner ID error") + } +} + +// TestMinutesSearchValidationQueryTooLong verifies overly long queries are rejected. +func TestMinutesSearchValidationQueryTooLong(t *testing.T) { + cmd := newMinutesSearchTestCommand() + _ = cmd.Flags().Set("query", strings.Repeat("a", 51)) + + runtime := common.TestNewRuntimeContext(cmd, defaultConfig()) + err := MinutesSearch.Validate(context.Background(), runtime) + if err == nil { + t.Fatal("expected query length error") + } + if !strings.Contains(err.Error(), "length must be between 1 and 50 characters") { + t.Fatalf("unexpected error: %v", err) + } +} + +// TestMinutesSearchValidationMaxPageSize30 verifies the maximum allowed page size passes validation. +func TestMinutesSearchValidationMaxPageSize30(t *testing.T) { + cmd := newMinutesSearchTestCommand() + _ = cmd.Flags().Set("query", "budget") + _ = cmd.Flags().Set("page-size", "30") + + runtime := common.TestNewRuntimeContext(cmd, defaultConfig()) + err := MinutesSearch.Validate(context.Background(), runtime) + if err != nil { + t.Fatalf("expected no error for --page-size 30, got: %v", err) + } +} + +// TestMinutesSearchValidationPageSizeAboveMax verifies page sizes above the limit are rejected. +func TestMinutesSearchValidationPageSizeAboveMax(t *testing.T) { + cmd := newMinutesSearchTestCommand() + _ = cmd.Flags().Set("query", "budget") + _ = cmd.Flags().Set("page-size", "31") + + runtime := common.TestNewRuntimeContext(cmd, defaultConfig()) + err := MinutesSearch.Validate(context.Background(), runtime) + if err == nil { + t.Fatal("expected validation error for --page-size 31") + } + if !strings.Contains(err.Error(), "--page-size") { + t.Fatalf("unexpected error: %v", err) + } +} + +// TestMinutesSearchValidationTimeErrors verifies time parsing failures surface through validation. +func TestMinutesSearchValidationTimeErrors(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + start string + end string + wantMessage string + }{ + {name: "invalid start", start: "bad-start", wantMessage: "--start:"}, + {name: "invalid end", end: "bad-end", wantMessage: "--end:"}, + {name: "start after end", start: "2026-03-26", end: "2026-03-25", wantMessage: "is after --end"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + cmd := newMinutesSearchTestCommand() + _ = cmd.Flags().Set("query", "budget") + if tt.start != "" { + _ = cmd.Flags().Set("start", tt.start) + } + if tt.end != "" { + _ = cmd.Flags().Set("end", tt.end) + } + + err := MinutesSearch.Validate(context.Background(), common.TestNewRuntimeContext(cmd, defaultConfig())) + if err == nil { + t.Fatal("expected validation error") + } + if !strings.Contains(err.Error(), tt.wantMessage) { + t.Fatalf("error = %v, want %q", err, tt.wantMessage) + } + }) + } +} + +// TestMinutesSearchDryRun verifies dry-run output includes the expected API request details. +func TestMinutesSearchDryRun(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig()) + err := mountAndRun(t, MinutesSearch, []string{"+search", "--query", "budget", "--owner-ids", "ou_owner,ou_owner_2", "--dry-run", "--as", "user"}, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout.String(), "/open-apis/minutes/v1/minutes/search") { + t.Fatalf("dry-run should show API path, got: %s", stdout.String()) + } + if !strings.Contains(stdout.String(), "\"method\": \"POST\"") { + t.Fatalf("dry-run should use POST, got: %s", stdout.String()) + } + if !strings.Contains(stdout.String(), "\"query\": \"budget\"") { + t.Fatalf("dry-run should show query in body, got: %s", stdout.String()) + } + if !strings.Contains(stdout.String(), "\"owner_ids\": [") || !strings.Contains(stdout.String(), "\"ou_owner\"") { + t.Fatalf("dry-run should show owner_ids in filter, got: %s", stdout.String()) + } +} + +// TestMinutesSearchExecuteRendersRowsAndMoreHint verifies pretty output renders rows and pagination hints. +func TestMinutesSearchExecuteRendersRowsAndMoreHint(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + searchStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/minutes/v1/minutes/search", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{ + "token": "minute_1", + "display_info": "周会摘要", + "meta_data": map[string]interface{}{ + "description": "周会纪要", + "app_link": "https://meetings.feishu.cn/minutes/obcn123", + "avatar": "https://p3-lark-file.byteimg.com/img/xxxx.jpg", + }, + }, + }, + "total": 1, + "has_more": true, + "page_token": "next_token", + }, + }, + } + reg.Register(searchStub) + + err := mountAndRun(t, MinutesSearch, []string{"+search", "--query", "budget", "--owner-ids", "me", "--format", "pretty", "--as", "user"}, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + reg.Verify(t) + + var body map[string]interface{} + if err := json.Unmarshal(searchStub.CapturedBody, &body); err != nil { + t.Fatalf("unmarshal request body: %v", err) + } + if body["query"] != "budget" { + t.Fatalf("request query = %v, want budget", body["query"]) + } + filter, _ := body["filter"].(map[string]interface{}) + if filter == nil { + t.Fatalf("request filter = %v, want object", body["filter"]) + } + owners, _ := filter["owner_ids"].([]interface{}) + if len(owners) != 1 || owners[0] != "ou_testuser" { + t.Fatalf("request owner_ids = %v, want [ou_testuser]", filter["owner_ids"]) + } + + out := stdout.String() + for _, want := range []string{"minute_1", "周会摘要", "周会纪要", "https://meetings.feishu.cn/minutes/obcn123", "https://p3-lark-file.byteimg.com/img/xxxx.jpg", "next_token", "more available"} { + if !strings.Contains(out, want) { + t.Fatalf("output missing %q, got: %s", want, out) + } + } +} + +// TestMinutesSearchExecuteNoMinutes verifies empty results render the no-data message. +func TestMinutesSearchExecuteNoMinutes(t *testing.T) { + t.Parallel() + + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/minutes/v1/minutes/search", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "items": []interface{}{}, + "total": 0, + "has_more": false, + "page_token": "", + }, + }, + }) + + err := mountAndRun(t, MinutesSearch, []string{"+search", "--query", "budget", "--format", "pretty", "--as", "user"}, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + reg.Verify(t) + if !strings.Contains(stdout.String(), "No minutes.") { + t.Fatalf("expected no minutes message, got: %s", stdout.String()) + } +} + +// TestMinutesSearchExecuteShowsPaginationHintForTableFormat verifies table output includes pagination hints. +func TestMinutesSearchExecuteShowsPaginationHintForTableFormat(t *testing.T) { + t.Parallel() + + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/minutes/v1/minutes/search", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{ + "token": "minute_1", + "display_info": "周会摘要", + "meta_data": map[string]interface{}{ + "description": "周会纪要", + "app_link": "https://meetings.feishu.cn/minutes/obcn123", + "avatar": "https://p3-lark-file.byteimg.com/img/xxxx.jpg", + }, + }, + }, + "total": 1, + "has_more": true, + "page_token": "next_token", + }, + }, + }) + + err := mountAndRun(t, MinutesSearch, []string{"+search", "--query", "budget", "--format", "table", "--as", "user"}, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + reg.Verify(t) + + out := stdout.String() + if !strings.Contains(out, "next_token") || !strings.Contains(out, "more available") { + t.Fatalf("expected pagination hint in table output, got: %s", out) + } +} + +// TestMinutesSearchExecuteJSONCountUsesRenderedRows verifies JSON metadata counts rendered rows only. +func TestMinutesSearchExecuteJSONCountUsesRenderedRows(t *testing.T) { + t.Parallel() + + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/minutes/v1/minutes/search", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "items": []interface{}{ + nil, + map[string]interface{}{ + "token": "minute_1", + "display_info": "周会摘要", + "meta_data": map[string]interface{}{ + "description": "周会纪要", + }, + }, + }, + "total": 2, + "has_more": false, + "page_token": "", + }, + }, + }) + + err := mountAndRun(t, MinutesSearch, []string{"+search", "--query", "budget", "--as", "user"}, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + reg.Verify(t) + + var envelope struct { + Meta struct { + Count int `json:"count"` + } `json:"meta"` + } + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("failed to parse output: %v\nraw: %s", err, stdout.String()) + } + if envelope.Meta.Count != 1 { + t.Fatalf("meta.count = %d, want 1", envelope.Meta.Count) + } +} + +// TestMinuteSearchFieldExtractors verifies field extractors read populated metadata correctly. +func TestMinuteSearchFieldExtractors(t *testing.T) { + t.Parallel() + + item := map[string]interface{}{ + "token": "minute_1", + "display_info": "周会摘要", + "meta_data": map[string]interface{}{ + "description": "周会纪要", + "app_link": "https://meetings.feishu.cn/minutes/obcn123", + "avatar": "https://p3-lark-file.byteimg.com/img/xxxx.jpg", + }, + } + + if got := minuteSearchToken(item); got != "minute_1" { + t.Fatalf("minuteSearchToken() = %q, want minute_1", got) + } + if got := minuteSearchDisplayInfo(item); got != "周会摘要" { + t.Fatalf("minuteSearchDisplayInfo() = %q", got) + } + if got := minuteSearchDescription(item); got != "周会纪要" { + t.Fatalf("minuteSearchDescription() = %q, want 周会纪要", got) + } + if got := minuteSearchAppLink(item); got != "https://meetings.feishu.cn/minutes/obcn123" { + t.Fatalf("minuteSearchAppLink() = %q", got) + } + if got := minuteSearchAvatar(item); got != "https://p3-lark-file.byteimg.com/img/xxxx.jpg" { + t.Fatalf("minuteSearchAvatar() = %q", got) + } +} + +// TestMinuteSearchFieldExtractorsFallbacks verifies extractors keep working for alternate sample data. +func TestMinuteSearchFieldExtractorsFallbacks(t *testing.T) { + t.Parallel() + + item := map[string]interface{}{ + "token": "minute_2", + "display_info": "回退摘要", + "meta_data": map[string]interface{}{ + "description": "回退纪要", + "app_link": "https://meetings.feishu.cn/minutes/fallback", + "avatar": "https://p3-lark-file.byteimg.com/img/fallback.jpg", + }, + } + + if got := minuteSearchToken(item); got != "minute_2" { + t.Fatalf("minuteSearchToken() = %q, want minute_2", got) + } + if got := minuteSearchDescription(item); got != "回退纪要" { + t.Fatalf("minuteSearchDescription() = %q, want 回退纪要", got) + } + if got := minuteSearchAppLink(item); got != "https://meetings.feishu.cn/minutes/fallback" { + t.Fatalf("minuteSearchAppLink() = %q", got) + } + if got := minuteSearchAvatar(item); got != "https://p3-lark-file.byteimg.com/img/fallback.jpg" { + t.Fatalf("minuteSearchAvatar() = %q", got) + } +} + +// TestMinuteSearchFieldExtractorsMissingMetaData verifies extractors fall back to empty values without metadata. +func TestMinuteSearchFieldExtractorsMissingMetaData(t *testing.T) { + t.Parallel() + + item := map[string]interface{}{ + "token": "minute_3", + "display_info": "无元信息摘要", + } + + if got := minuteSearchToken(item); got != "minute_3" { + t.Fatalf("minuteSearchToken() = %q, want minute_3", got) + } + if got := minuteSearchDescription(item); got != "" { + t.Fatalf("minuteSearchDescription() = %q, want empty", got) + } + if got := minuteSearchAppLink(item); got != "" { + t.Fatalf("minuteSearchAppLink() = %q, want empty", got) + } + if got := minuteSearchAvatar(item); got != "" { + t.Fatalf("minuteSearchAvatar() = %q, want empty", got) + } +} diff --git a/shortcuts/minutes/shortcuts.go b/shortcuts/minutes/shortcuts.go index 9c1431f29..9c0651e73 100644 --- a/shortcuts/minutes/shortcuts.go +++ b/shortcuts/minutes/shortcuts.go @@ -8,6 +8,7 @@ import "github.com/larksuite/cli/shortcuts/common" // Shortcuts returns all minutes shortcuts. func Shortcuts() []common.Shortcut { return []common.Shortcut{ + MinutesSearch, MinutesDownload, } } diff --git a/skills/lark-minutes/SKILL.md b/skills/lark-minutes/SKILL.md index eb7962c8d..283621729 100644 --- a/skills/lark-minutes/SKILL.md +++ b/skills/lark-minutes/SKILL.md @@ -1,7 +1,7 @@ --- name: lark-minutes version: 1.0.0 -description: "飞书妙记:获取妙记基础信息(标题、封面、时长)和相关的 AI 产物(总结、待办、章节),下载妙记音视频文件。飞书妙记的 URL 格式为: http(s):///minutes/" +description: "飞书妙记:妙记相关基本功能。1.查询妙记列表(按关键词/所有者/参与者/时间范围);2.获取妙记基础信息(标题、封面、时长 等);3.下载妙记音视频文件;4.获取妙记相关 AI 产物(总结、待办、章节)。飞书妙记 URL 格式: http(s):///minutes/" metadata: requires: bins: ["lark-cli"] @@ -14,64 +14,84 @@ metadata: ## 核心概念 -- **妙记 Token(minute_token)**:妙记的唯一标识符。通常可从妙记的 URL 链接中提取(例如 `https://*.feishu.cn/minutes/obcnq3b9jl72l83w4f14xxxx` 中的最后一段字符串 `obcnq3b9jl72l83w4f14xxxx`)。 +- **妙记(Minutes)**:来源于飞书视频会议的录制产物或用户上传的音视频文件,通过 `minute_token` 标识。 +- **妙记 Token(minute\_token)**:妙记的唯一标识符,可从妙记 URL 末尾提取(例如 `https://*.feishu.cn/minutes/obcnq3b9jl72l83w4f14xxxx` 中的 `obcnq3b9jl72l83w4f14xxxx`)。如果 URL 中包含额外参数(如 `?xxx`),应截取路径最后一段。 -## 使用说明 +## 核心场景 -1. **提取 Token**: - - 只有 `minute_token` 参数是必填的。 - - 如果 URL 中包含额外参数(如 `?xxx`),请截取路径部分的最后一段作为 token。 - - 示例:从 `https://domain.feishu.cn/minutes/obc123456?project=xxx` 中提取出 `obc123456`。 +### 1. 搜索妙记 -2. **获取妙记信息**: - - 使用 `lark-cli schema minutes.minutes.get` 可以查看具体的返回值结构。 - - 返回的核心字段通常包含: - - `title`:会议标题 - - `cover`:视频/音频封面 URL - - `duration`:会议时长(毫秒) - - `owner_id`:所有者 ID - - `url`:妙记链接 +1. 当用户描述的是"我的妙记""包含某个关键词的妙记""某段时间内的妙记",优先使用 `minutes +search`。 +2. 仅支持使用关键词、时间段、参与者、所有者等筛选条件搜索妙记记录,对于不支持的筛选条件,需要提示用户。 +3. 搜索结果存在多条数据时,务必注意分页数据获取,不要遗漏任何妙记记录。 +4. 如果是会议的妙记,应优先使用 [vc +search](../lark-vc/references/lark-vc-search.md) 先定位会议,再按需通过 [vc +recording](../lark-vc/references/lark-vc-recording.md) 获取 `minute_token`。 -## 典型场景 -### 妙记内容查询 +### 2. 查看妙记基础信息 -```bash -# 首先查询妙记元信息(标题、时长、封面) → 用本 skill -lark-cli minutes minutes get --params '{"minute_token": "obcn***************"}' - -# 查妙记关联的纪要产物:逐字稿、总结、待办、章节等 → 用 lark-cli vc +notes -lark-cli vc +notes --minute-tokens obcnhijv43vq6bcsl5xasfb2 -``` -本 skill 仅提供妙记**基础元信息**查询(标题、封面、时长)。如需获取纪要**内容**(逐字稿、AI 总结、待办、章节),请使用 [lark-cli vc +notes](../lark-vc/references/lark-vc-notes.md): +1. 当用户只需要确认某条妙记的标题、封面、时长、所有者、URL 等基础信息时,使用 `minutes minutes get`。 +2. 如果用户给的是妙记 URL,应先从 URL 末尾提取 `minute_token`,再调用 `minutes minutes get`。 +3. 用户意图不明确时,默认先给基础元信息,帮助确认是否命中目标妙记。 -- 用户未指定需要查询妙记的哪些内容时,默认查询基础元信息和相关联的纪要产物信息。 -- 用户未明确指定查看纪要产物(逐字稿、总结、待办、章节)时,向用户展示对应产物的链接即可,不需要直接读取产物内容。 +> 使用 `lark-cli schema minutes.minutes.get` 可查看完整返回值结构。核心字段包含:`title`(标题)、`cover`(封面 URL)、`duration`(时长,毫秒)、`owner_id`(所有者 ID)、`url`(妙记链接)。 -## Shortcuts(推荐优先使用) +### 3. 下载妙记音视频文件 -Shortcut 是对常用操作的高级封装(`lark-cli minutes + [flags]`)。有 Shortcut 的操作优先使用。 +1. 下载妙记音视频文件到本地,或获取有效期 1 天的下载链接。详见 [minutes +download](references/lark-minutes-download.md)。 +2. `minutes +download` 只负责音视频媒体文件。 +3. 用户只想拿可分享的下载地址时,使用 `--url-only`;用户要落地到本地文件时,直接下载。 -| Shortcut | 说明 | -|----------|------| -| [`+download`](references/lark-minutes-download.md) | Download audio/video media file of a minute | +> **注意**:`+download` 只负责音视频媒体文件。如果用户需要的是逐字稿、总结、待办、章节等纪要内容,请使用 [vc +notes --minute-tokens](../lark-vc/references/lark-vc-notes.md)。 -### 妙记音视频下载 +### 4. 获取妙记的逐字稿、总结、待办、章节 -下载妙记音视频文件到本地,或获取有效期 1 天的下载链接。详见 [minutes +download](references/lark-minutes-download.md)。 +1. 当用户说"这个妙记的逐字稿""总结""待办""章节"时,**不属于本 skill**。 +2. 应使用 [vc +notes --minute-tokens](../lark-vc/references/lark-vc-notes.md) 获取对应的纪要产物。 +3. 如果当前上下文中已有 `minute_token`,可直接传给 `vc +notes`;如果只有妙记 URL,先提取 `minute_token`。 ```bash -# 下载音视频文件到本地 -lark-cli minutes +download --minute-tokens obcnq3b9jl72l83w4f149w9c --output ./meeting.mp4 +# 通过 minute_token 获取纪要产物(逐字稿、总结、待办、章节) +lark-cli vc +notes --minute-tokens +``` -# 仅获取下载链接 -lark-cli minutes +download --minute-tokens obcnq3b9jl72l83w4f149w9c --url-only +> **跨 skill 路由**:逐字稿、AI 总结、待办、章节等纪要内容由 [lark-vc](../lark-vc/SKILL.md) 的 `+notes` 命令提供 -# 批量下载 -lark-cli minutes +download --minute-tokens obcnq3b9jl72l83w4f149w9c,obcnexa7814k4t41c446fzwj +## 资源关系 + +```text +Minutes (妙记) ← minute_token 标识 +├── Metadata (标题、封面、时长、owner、url) → minutes minutes get +└── MediaFile (音频/视频文件) → minutes +download ``` +> **能力边界**:`minutes` 负责 **搜索妙记、查看基础元信息、下载音视频文件**。 +> +> **路由规则**: +> +> - 用户说"妙记列表 / 搜索妙记 / 某个关键词的妙记" → `minutes +search` +> - 用户只是想看"我的妙记 / 某段时间内的妙记 / 妙记列表",不要先走 [lark-vc](../lark-vc/SKILL.md),而应直接使用本 skill +> - 用户如果同时提到"会议 / 会 / 开会 / 某场会",即使也提到了"妙记",也应优先走 [lark-vc](../lark-vc/SKILL.md) 先定位会议,再通过 [vc +recording](../lark-vc/references/lark-vc-recording.md) 获取 `minute_token` +> - 用户说"我的妙记 / 我拥有的妙记 / 我参与的妙记"时,可将相关过滤条件映射为 `me`;`me` 表示当前用户 +> - 结果有多页时,使用 `page_token` 持续翻页,直到确认没有更多结果 +> - `minutes +search` 单次最多返回 `200` 条;结果总数没有固定上限 +> - 用户说"这个妙记的标题 / 时长 / 封面 / 链接" → `minutes minutes get` +> - 用户说"下载这个妙记的视频 / 音频 / 媒体文件" → `minutes +download` +> - 用户说"这个妙记的逐字稿 / 总结 / 待办 / 章节" → 使用 [vc +notes --minute-tokens](../lark-vc/references/lark-vc-notes.md) + +## Shortcuts(推荐优先使用) + +Shortcut 是对常用操作的高级封装(`lark-cli minutes + [flags]`)。有 Shortcut 的操作优先使用。 + +| Shortcut | 说明 | +| -------------------------------------------------- | --------------------------------------------------------------- | +| [`+search`](references/lark-minutes-search.md) | Search minutes by keyword, owners, participants, and time range | +| [`+download`](references/lark-minutes-download.md) | Download audio/video media file of a minute | + +- 使用 `+search` 命令时,必须阅读 [references/lark-minutes-search.md](references/lark-minutes-search.md),了解搜索参数和返回值结构。 +- 使用 `+download` 命令时,必须阅读 [references/lark-minutes-download.md](references/lark-minutes-download.md),了解下载参数和返回值结构。 + + ## API Resources ```bash @@ -83,14 +103,14 @@ lark-cli minutes [flags] # 调用 API ### minutes - - `get` — 获取妙记信息 +- `get` — 获取妙记信息 ## 权限表 -| 方法 | 所需 scope | -|------|-----------| -| `minutes.get` | `minutes:minutes:readonly` | -| `+download` | `minutes:minutes.media:export` | - +| 方法 | 所需 scope | +| ------------- | ------------------------------ | +| `+search` | `minutes:minutes.search:read` | +| `minutes.get` | `minutes:minutes:readonly` | +| `+download` | `minutes:minutes.media:export` | diff --git a/skills/lark-minutes/references/lark-minutes-search.md b/skills/lark-minutes/references/lark-minutes-search.md new file mode 100644 index 000000000..f3c2fb35e --- /dev/null +++ b/skills/lark-minutes/references/lark-minutes-search.md @@ -0,0 +1,180 @@ +# minutes +search + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +搜索妙记列表,支持关键词、所有者、参与者以及时间范围等多条件过滤。所有者与参与者都支持传入多个 open\_id,也支持传入 `me` 表示当前用户。只读操作,不修改任何妙记数据。 + +本 skill 对应 shortcut:`lark-cli minutes +search`(调用 `POST /open-apis/minutes/v1/minutes/search`)。 + +## 典型触发表达 + +以下说法通常应优先使用 `minutes +search`: + +- 我的妙记 +- 我拥有的妙记 +- 我参与的妙记 +- 最近的妙记 +- 某个关键词的妙记 +- 某段时间内的妙记 + +## 命令 + +```bash +# 关键词搜索 +lark-cli minutes +search --query "预算复盘" + +# 查询某一天内的妙记(单日查询时,建议将 start 和 end 都填写为同一天) +lark-cli minutes +search --start 2026-03-10 --end 2026-03-10 + +# 按时间范围搜索 +lark-cli minutes +search --start "2026-03-10T00:00+08:00" --end "2026-03-17T00:00+08:00" +lark-cli minutes +search --start 2026-03-10 --end 2026-03-17 + +# 关键词 + 时间范围 +lark-cli minutes +search --query "预算复盘" --start "2026-03-10T00:00+08:00" --end "2026-03-17T00:00+08:00" +lark-cli minutes +search --query "预算复盘" --start "2026-03-10T00:00+08:00" +lark-cli minutes +search --query "预算复盘" --end "2026-03-17T00:00+08:00" + +# 按参与者过滤(open_id,逗号分隔) +lark-cli minutes +search --participant-ids "ou_x,ou_y" + +# 按所有者过滤(open_id,逗号分隔) +lark-cli minutes +search --owner-ids "ou_owner,ou_owner_2" + +# 查询我参与的妙记 +lark-cli minutes +search --participant-ids "me" + +# 查询我拥有的妙记 +lark-cli minutes +search --owner-ids "me" + +# 多条件组合查询 +lark-cli minutes +search --owner-ids "ou_owner" --participant-ids "ou_x" --start "2026-03-10T00:00+08:00" + +# 分页查询 +lark-cli minutes +search --query "预算复盘" --page-size 20 +lark-cli minutes +search --query "预算复盘" --page-size 20 --page-token '' + +# 输出为结构化 JSON +lark-cli minutes +search --query "预算复盘" --format json +``` + +## 参数 + +| 参数 | 必填 | 说明 | +| ------------------------- | -- | ------------------------------------ | +| `--query ` | 否 | 搜索关键词 | +| `--owner-ids ` | 否 | 所有者 open\_id 列表,逗号分隔;支持传 `me` 表示当前用户 | +| `--participant-ids ` | 否 | 参与者 open\_id 列表,逗号分隔;支持传 `me` 表示当前用户 | +| `--start