From b87f48d53262d0bc7d059d566396c3364fc1b0d9 Mon Sep 17 00:00:00 2001 From: houzhicong Date: Thu, 9 Apr 2026 11:42:06 +0800 Subject: [PATCH 01/12] feat: support minutes search by keyword and owner --- shortcuts/minutes/minutes_search.go | 368 +++++++++++++ shortcuts/minutes/minutes_search_test.go | 512 ++++++++++++++++++ shortcuts/minutes/shortcuts.go | 1 + skills/lark-minutes/SKILL.md | 113 ++-- .../references/lark-minutes-search.md | 172 ++++++ 5 files changed, 1119 insertions(+), 47 deletions(-) create mode 100644 shortcuts/minutes/minutes_search.go create mode 100644 shortcuts/minutes/minutes_search_test.go create mode 100644 skills/lark-minutes/references/lark-minutes-search.md diff --git a/shortcuts/minutes/minutes_search.go b/shortcuts/minutes/minutes_search.go new file mode 100644 index 000000000..29f43d708 --- /dev/null +++ b/shortcuts/minutes/minutes_search.go @@ -0,0 +1,368 @@ +// 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" + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" +) + +const ( + defaultMinutesSearchPageSize = 15 + maxMinutesSearchPageSize = 200 + maxMinutesSearchQueryLen = 50 +) + +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, _ := time.Parse(time.RFC3339, startTime) + et, _ := time.Parse(time.RFC3339, endTime) + if st.After(et) { + return "", "", output.ErrValidation("--start (%s) is after --end (%s)", start, end) + } + } + return startTime, endTime, nil +} + +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 +} + +func resolveUserIDs(ids []string, runtime *common.RuntimeContext) []string { + if len(ids) == 0 { + return 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") && currentUserID != "" { + id = currentUserID + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + return out +} + +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 +} + +func buildMinutesSearchFilter(runtime *common.RuntimeContext, startTime, endTime string) map[string]interface{} { + filter := map[string]interface{}{} + + ownerIDs := resolveUserIDs(common.SplitCSV(runtime.Str("owner-ids")), runtime) + if len(ownerIDs) > 0 { + filter["owner_ids"] = ownerIDs + } + + participantIDs := resolveUserIDs(common.SplitCSV(runtime.Str("participant-ids")), runtime) + 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 + } + return filter +} + +func buildMinutesSearchBody(runtime *common.RuntimeContext, startTime, endTime string) map[string]interface{} { + body := map[string]interface{}{} + + if q := strings.TrimSpace(runtime.Str("query")); q != "" { + body["query"] = q + } + + if filter := buildMinutesSearchFilter(runtime, startTime, endTime); filter != nil { + body["filter"] = filter + } + + return body +} + +func buildMinutesSearchParams(runtime *common.RuntimeContext) larkcore.QueryParams { + params := larkcore.QueryParams{} + + pageSize := defaultMinutesSearchPageSize + if runtime.Cmd != nil && runtime.Cmd.Flags().Changed("page-size") { + if v := runtime.Int("page-size"); v > 0 { + pageSize = v + } + } + params["page_size"] = []string{fmt.Sprintf("%d", pageSize)} + + if pageToken := strings.TrimSpace(runtime.Str("page-token")); pageToken != "" { + params["page_token"] = []string{pageToken} + } + + return params +} + +func minuteSearchItems(data map[string]interface{}) []interface{} { + if items := common.GetSlice(data, "items"); items != nil { + return items + } + for _, key := range []string{"minute_list", "minutes"} { + if items := common.GetSlice(data, key); items != nil { + return items + } + } + return nil +} + +func minuteSearchToken(item map[string]interface{}) string { + for _, key := range []string{"token", "minute_token", "id"} { + if value := common.GetString(item, key); value != "" { + return value + } + } + return "" +} + +func minuteSearchDisplayInfo(item map[string]interface{}) string { + return common.GetString(item, "display_info") +} + +func minuteSearchTitle(item map[string]interface{}) string { + meta := common.GetMap(item, "meta_data") + for _, key := range []string{"title", "topic", "name"} { + if value := common.GetString(meta, key); value != "" { + return value + } + if value := common.GetString(item, key); value != "" { + return value + } + } + return "" +} + +func minuteSearchCreateTime(item map[string]interface{}) string { + meta := common.GetMap(item, "meta_data") + for _, key := range []string{"create_time", "start_time", "start_ms"} { + if value := meta[key]; value != nil { + if formatted := common.FormatTime(value); formatted != "" && formatted != fmt.Sprintf("%v", value) { + return formatted + } + if raw := fmt.Sprintf("%v", value); raw != "" && raw != "" { + return raw + } + } + } + for _, key := range []string{"create_time", "start_time", "start_ms"} { + value := item[key] + if value == nil { + continue + } + if formatted := common.FormatTime(value); formatted != "" && formatted != fmt.Sprintf("%v", value) { + return formatted + } + if raw := fmt.Sprintf("%v", value); raw != "" && raw != "" { + return raw + } + } + return "" +} + +func minuteSearchURL(item map[string]interface{}) string { + meta := common.GetMap(item, "meta_data") + for _, key := range []string{"url", "minute_url", "link"} { + if value := common.GetString(meta, key); value != "" { + return value + } + if value := common.GetString(item, key); value != "" { + return value + } + } + return "" +} + +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", Type: "int", Default: "15", Desc: "page size, 1-200 (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 runtime.Cmd != nil && runtime.Cmd.Flags().Changed("page-size") { + pageSize := runtime.Int("page-size") + if pageSize < 1 || pageSize > maxMinutesSearchPageSize { + return common.FlagErrorf("invalid --page-size %d: must be between %d and %d", pageSize, 1, maxMinutesSearchPageSize) + } + } + for _, id := range resolveUserIDs(common.SplitCSV(runtime.Str("owner-ids")), runtime) { + if _, err := common.ValidateUserID(id); err != nil { + return err + } + } + for _, id := range resolveUserIDs(common.SplitCSV(runtime.Str("participant-ids")), runtime) { + 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) + dryRunParams := map[string]interface{}{} + for key, values := range params { + if len(values) == 1 { + dryRunParams[key] = values[0] + } + } + dryRun := common.NewDryRunAPI(). + POST("/open-apis/minutes/v1/minutes/search") + if len(dryRunParams) > 0 { + dryRun.Params(dryRunParams) + } + return dryRun.Body(buildMinutesSearchBody(runtime, startTime, endTime)) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + startTime, endTime, err := parseTimeRange(runtime) + if err != nil { + return err + } + + params := map[string]interface{}{} + pageSize := defaultMinutesSearchPageSize + if runtime.Cmd != nil && runtime.Cmd.Flags().Changed("page-size") { + if v := runtime.Int("page-size"); v > 0 { + pageSize = v + } + } + params["page_size"] = pageSize + if pageToken := strings.TrimSpace(runtime.Str("page-token")); pageToken != "" { + params["page_token"] = pageToken + } + + data, err := runtime.CallAPI(http.MethodPost, "/open-apis/minutes/v1/minutes/search", params, buildMinutesSearchBody(runtime, startTime, endTime)) + 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) + + 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(items)}, func(w io.Writer) { + if len(items) == 0 { + fmt.Fprintln(w, "No minutes.") + return + } + + var rows []map[string]interface{} + 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), + "title": common.TruncateStr(minuteSearchTitle(item), 40), + "create_time": minuteSearchCreateTime(item), + "url": common.TruncateStr(minuteSearchURL(item), 80), + }) + } + output.PrintTable(w, rows) + if hasMore { + fmt.Fprintf(w, "\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..ac1d91a19 --- /dev/null +++ b/shortcuts/minutes/minutes_search_test.go @@ -0,0 +1,512 @@ +// 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/httpmock" + "github.com/larksuite/cli/shortcuts/common" + "github.com/spf13/cobra" +) + +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().Int("page-size", 20, "") + return cmd +} + +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) + } +} + +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 { + tt := tt + 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) + } + }) + } +} + +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 := buildMinutesSearchBody(runtime, "2026-03-24T00:00:00Z", "2026-03-25T00:00:00Z") + + if got := params.Get("page_size"); got != "5" { + t.Fatalf("page_size = %q, want 5", got) + } + if got := params.Get("page_token"); 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"]) + } +} + +func TestBuildMinutesSearchParamsDefaultPageSize(t *testing.T) { + t.Parallel() + + cmd := newMinutesSearchTestCommand() + + params := buildMinutesSearchParams(common.TestNewRuntimeContext(cmd, defaultConfig())) + if got := params.Get("page_size"); got != "15" { + t.Fatalf("page_size = %q, want 15", got) + } +} + +func TestResolveUserIDs(t *testing.T) { + t.Parallel() + + cmd := &cobra.Command{Use: "test"} + runtime := common.TestNewRuntimeContext(cmd, defaultConfig()) + + got := resolveUserIDs([]string{"me"}, runtime) + if len(got) != 1 || got[0] != "ou_testuser" { + t.Fatalf("resolveUserIDs([me]) = %v, want [ou_testuser]", got) + } + + got = resolveUserIDs([]string{"ou_other", "me", "Me"}, runtime) + 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 = resolveUserIDs(nil, runtime) + if got != nil { + t.Fatalf("resolveUserIDs(nil) = %v, want nil", got) + } +} + +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"]) + } +} + +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) + } +} + +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 := buildMinutesSearchBody(runtime, "", "") + + 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) + } +} + +func TestMinuteSearchItems(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + data map[string]interface{} + }{ + {name: "items", data: map[string]interface{}{"items": []interface{}{map[string]interface{}{"token": "tok_1"}}}}, + {name: "minute_list", data: map[string]interface{}{"minute_list": []interface{}{map[string]interface{}{"token": "tok_2"}}}}, + {name: "minutes", data: map[string]interface{}{"minutes": []interface{}{map[string]interface{}{"token": "tok_3"}}}}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + items := minuteSearchItems(tt.data) + 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) + } +} + +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) + } +} + +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") + } +} + +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") + } +} + +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) + } +} + +func TestMinutesSearchValidationMaxPageSize200(t *testing.T) { + cmd := newMinutesSearchTestCommand() + _ = cmd.Flags().Set("query", "budget") + _ = cmd.Flags().Set("page-size", "200") + + runtime := common.TestNewRuntimeContext(cmd, defaultConfig()) + err := MinutesSearch.Validate(context.Background(), runtime) + if err != nil { + t.Fatalf("expected no error for --page-size 200, got: %v", err) + } +} + +func TestMinutesSearchValidationPageSizeAboveMax(t *testing.T) { + cmd := newMinutesSearchTestCommand() + _ = cmd.Flags().Set("query", "budget") + _ = cmd.Flags().Set("page-size", "201") + + runtime := common.TestNewRuntimeContext(cmd, defaultConfig()) + err := MinutesSearch.Validate(context.Background(), runtime) + if err == nil { + t.Fatal("expected validation error for --page-size 201") + } + if !strings.Contains(err.Error(), "--page-size") { + t.Fatalf("unexpected error: %v", err) + } +} + +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 { + tt := tt + 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) + } + }) + } +} + +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()) + } +} + +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{}{ + "minute_list": []interface{}{ + map[string]interface{}{ + "minute_token": "minute_1", + "display_info": "周会摘要", + "topic": "周会纪要", + "link": "https://meetings.feishu.cn/minutes/obcn123", + "start_ms": "1775144288000", + }, + }, + "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", "next_token", "more available"} { + if !strings.Contains(out, want) { + t.Fatalf("output missing %q, got: %s", want, out) + } + } +} + +func TestMinutesSearchExecuteNoMinutes(t *testing.T) { + 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()) + } +} + +func TestMinuteSearchFieldExtractors(t *testing.T) { + t.Parallel() + + item := map[string]interface{}{ + "token": "minute_1", + "display_info": "周会摘要", + "meta_data": map[string]interface{}{ + "title": "周会纪要", + "url": "https://meetings.feishu.cn/minutes/obcn123", + "create_time": "1775144288000", + }, + } + + 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 := minuteSearchTitle(item); got != "周会纪要" { + t.Fatalf("minuteSearchTitle() = %q, want 周会纪要", got) + } + if got := minuteSearchURL(item); got != "https://meetings.feishu.cn/minutes/obcn123" { + t.Fatalf("minuteSearchURL() = %q", got) + } + if got := minuteSearchCreateTime(item); got == "" { + t.Fatal("minuteSearchCreateTime() should not be empty") + } +} + +func TestMinuteSearchFieldExtractorsFallbacks(t *testing.T) { + t.Parallel() + + item := map[string]interface{}{ + "id": "minute_2", + "display_info": "回退摘要", + "name": "回退纪要", + "minute_url": "https://meetings.feishu.cn/minutes/fallback", + "start_time": "bad-ts", + } + + if got := minuteSearchToken(item); got != "minute_2" { + t.Fatalf("minuteSearchToken() = %q, want minute_2", got) + } + if got := minuteSearchTitle(item); got != "回退纪要" { + t.Fatalf("minuteSearchTitle() = %q, want 回退纪要", got) + } + if got := minuteSearchURL(item); got != "https://meetings.feishu.cn/minutes/fallback" { + t.Fatalf("minuteSearchURL() = %q", got) + } + if got := minuteSearchCreateTime(item); got != "bad-ts" { + t.Fatalf("minuteSearchCreateTime() = %q, want bad-ts", 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..1c4d31ce1 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.获取妙记基础信息(标题、封面、时长、owner 等);3.下载妙记音视频文件。妙记 URL 格式: http(s):///minutes/" metadata: requires: bins: ["lark-cli"] @@ -14,64 +14,83 @@ 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. 当用户说"我的""我自己的""我参与的""我拥有的"时,可优先将相关过滤条件映射为 `me`;其中 `me` 表示当前用户。 +3. 搜索结果中的 `minute_token` 是后续所有动作的核心输入,应优先复用,而不是重复搜索。 +4. 搜索结果存在多条数据时,务必使用 `page_token` 持续翻页,直到确认没有更多结果,避免遗漏妙记。 +5. 单次查询最多返回 `200` 条,结果总数没有固定上限;不要把单页结果误认为全量结果。 +6. 如果用户要找的是未来会议安排,而不是已经生成的妙记,不应使用 `minutes +search`,应改走日历或会议搜索能力。 +7. 如果用户只是想查询妙记列表,而不是会议记录、纪要内容或逐字稿,应直接停留在本 skill,不要先走 [lark-vc](../lark-vc/SKILL.md)。 -## 典型场景 +### 2. 查看妙记基础信息 -### 妙记内容查询 +1. 当用户只需要确认某条妙记的标题、封面、时长、所有者、URL 等基础信息时,使用 `minutes minutes get`。 +2. 如果用户给的是妙记 URL,应先从 URL 末尾提取 `minute_token`,再调用 `minutes minutes get`。 +3. 用户意图不明确时,默认先给基础元信息,帮助确认是否命中目标妙记。 -```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): - -- 用户未指定需要查询妙记的哪些内容时,默认查询基础元信息和相关联的纪要产物信息。 -- 用户未明确指定查看纪要产物(逐字稿、总结、待办、章节)时,向用户展示对应产物的链接即可,不需要直接读取产物内容。 +> 使用 `lark-cli schema minutes.minutes.get` 可查看完整返回值结构。核心字段包含:`title`(标题)、`cover`(封面 URL)、`duration`(时长,毫秒)、`owner_id`(所有者 ID)、`url`(妙记链接)。 -## Shortcuts(推荐优先使用) +### 3. 下载妙记音视频文件 -Shortcut 是对常用操作的高级封装(`lark-cli minutes + [flags]`)。有 Shortcut 的操作优先使用。 +1. 当用户说"下载妙记""下载录音文件""下载视频""导出媒体文件"时,使用 `minutes +download`。 +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 +``` + +> **跨 skill 路由**:逐字稿、AI 总结、待办、章节等纪要内容由 [lark-vc](../lark-vc/SKILL.md) 的 `+notes` 命令提供 -# 仅获取下载链接 -lark-cli minutes +download --minute-tokens obcnq3b9jl72l83w4f149w9c --url-only +## 资源关系 -# 批量下载 -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 +> - 用户说"我的妙记 / 我拥有的妙记 / 我参与的妙记"时,可将相关过滤条件映射为 `me`;`me` 表示当前用户 +> - 结果有多页时,使用 `page_token` 持续翻页,直到确认没有更多结果 +> - `minutes +search` 单次最多返回 `200` 条;结果总数没有固定上限 +> - 用户说"这个妙记的标题 / 时长 / 封面 / 链接" → `minutes minutes get` +> - 用户说"下载这个妙记的视频 / 音频 / 媒体文件" → `minutes +download` +> - 用户说"这个妙记的逐字稿 / 总结 / 待办 / 章节" → 使用 [vc +notes --minute-tokens](../lark-vc/references/lark-vc-notes.md) +> - 用户说"未来会议 / 之后的会议安排 / 还没开始的会议" → 不属于 `minutes`,应改走日历或会议能力 + +## 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 | + + ## API Resources ```bash @@ -83,14 +102,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..65ba15d2d --- /dev/null +++ b/skills/lark-minutes/references/lark-minutes-search.md @@ -0,0 +1,172 @@ +# 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 "预算复盘" + +# 按时间范围搜索 +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